diff --git a/.env.example b/.env.example index e2b6492..3ae9839 100644 --- a/.env.example +++ b/.env.example @@ -3,9 +3,11 @@ DB_HOST=db DB_USER=root DB_PASSWORD=root_password DB_NAME=agenciac_comia +JWT_SECRET=your_jwt_secret_here -# Gitea Runner Configuration -GITEA_INSTANCE_URL=https://gitea.blyzer.com.br -GITEA_RUNNER_REGISTRATION_TOKEN=your_token_here -GITEA_RUNNER_NAME=fasto-runner -GITEA_RUNNER_LABELS=ubuntu-latest:docker://node:16-bullseye +# Mailer Configuration +SMTP_HOST=mail.blyzer.com.br +SMTP_PORT=587 +SMTP_USER=nao-responda@blyzer.com.br +SMTP_PASS=your_smtp_password_here +MAIL_FROM=nao-responda@blyzer.com.br diff --git a/App.tsx b/App.tsx index 929dd5a..984ab3a 100644 --- a/App.tsx +++ b/App.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { HashRouter as Router, Routes, Route, Navigate, useLocation } from 'react-router-dom'; import { Layout } from './components/Layout'; import { Dashboard } from './pages/Dashboard'; @@ -6,15 +6,52 @@ import { UserDetail } from './pages/UserDetail'; import { AttendanceDetail } from './pages/AttendanceDetail'; import { SuperAdmin } from './pages/SuperAdmin'; import { TeamManagement } from './pages/TeamManagement'; +import { Teams } from './pages/Teams'; import { Login } from './pages/Login'; +import { ForgotPassword } from './pages/ForgotPassword'; +import { ResetPassword } from './pages/ResetPassword'; import { UserProfile } from './pages/UserProfile'; +import { getUserById } from './services/dataService'; +import { User } from './types'; -const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => { +const AuthGuard: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); const location = useLocation(); - const isLoginPage = location.pathname === '/login'; - if (isLoginPage) { - return <>{children}; + useEffect(() => { + const checkAuth = async () => { + const storedUserId = localStorage.getItem('ctms_user_id'); + if (!storedUserId) { + setLoading(false); + return; + } + + try { + const fetchedUser = await getUserById(storedUserId); + if (fetchedUser && fetchedUser.status === 'active') { + setUser(fetchedUser); + } else { + localStorage.removeItem('ctms_user_id'); + localStorage.removeItem('ctms_token'); + localStorage.removeItem('ctms_tenant_id'); + setUser(null); + } + } catch (err) { + console.error("Auth check failed", err); + } finally { + setLoading(false); + } + }; + checkAuth(); + }, [location.pathname]); + + if (loading) { + return
Carregando...
; + } + + if (!user) { + return ; } return {children}; @@ -23,21 +60,21 @@ const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => { const App: React.FC = () => { return ( - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + ); }; -export default App; \ No newline at end of file +export default App; diff --git a/backend/index.js b/backend/index.js index b1d5730..8fa74f3 100644 --- a/backend/index.js +++ b/backend/index.js @@ -1,196 +1,401 @@ - const express = require('express'); const cors = require('cors'); const path = require('path'); +const bcrypt = require('bcryptjs'); +const jwt = require('jsonwebtoken'); +const crypto = require('crypto'); +const nodemailer = require('nodemailer'); const pool = require('./db'); const app = express(); -const PORT = process.env.PORT || 3001; // Porta do backend +const PORT = process.env.PORT || 3001; +const JWT_SECRET = process.env.JWT_SECRET || 'fasto_super_secret_key'; -app.use(cors()); // Permite que o React (localhost:3000) acesse este servidor +// Configuração do Transportador de E-mail +const transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST || 'mail.blyzer.com.br', + port: parseInt(process.env.SMTP_PORT) || 587, + secure: false, // false para 587 (STARTTLS) + auth: { + user: process.env.SMTP_USER || 'nao-responda@blyzer.com.br', + pass: process.env.SMTP_PASS || '', + }, + tls: { + ciphers: 'SSLv3', + rejectUnauthorized: false + }, + debug: true, // Habilitar debug + logger: true // Logar no console +}); + +app.use(cors()); app.use(express.json()); -// Serve static files from the React app -if (process.env.NODE_ENV === 'production') { - app.use(express.static(path.join(__dirname, '../dist'))); -} - -// --- 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 }); - } +// Logger de Requisições +app.use((req, res, next) => { + console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`); + next(); }); -// 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 }); - } -}); +// --- API Router --- +const apiRouter = express.Router(); -// Atualizar Usuário -app.put('/api/users/:id', async (req, res) => { - const { name, bio } = req.body; +// --- Auth Routes --- + +// Register +apiRouter.post('/auth/register', async (req, res) => { + const { name, email, password, organizationName } = req.body; try { + const [existing] = await pool.query('SELECT id FROM users WHERE email = ?', [email]); + if (existing.length > 0) return res.status(400).json({ error: 'E-mail já cadastrado.' }); + + const passwordHash = await bcrypt.hash(password, 10); + const verificationCode = Math.floor(100000 + Math.random() * 900000).toString(); + const expiresAt = new Date(Date.now() + 15 * 60 * 1000); + await pool.query( - 'UPDATE users SET name = ?, bio = ? WHERE id = ?', - [name, bio, req.params.id] + 'INSERT INTO pending_registrations (email, password_hash, full_name, organization_name, verification_code, expires_at) VALUES (?, ?, ?, ?, ?, ?)', + [email, passwordHash, name, organizationName, verificationCode, expiresAt] ); - res.json({ message: 'User updated successfully' }); - } catch (error) { - console.error('Erro ao atualizar usuário:', 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) + const mailOptions = { + from: `"Fasto" <${process.env.MAIL_FROM || 'nao-responda@blyzer.com.br'}>`, + to: email, + subject: 'Seu código de verificação Fasto', + text: `Olá ${name}, seu código de verificação é: ${verificationCode}`, + html: ` +
+

Bem-vindo ao Fasto!

+

Seu código: ${verificationCode}

+
` }; - - res.json(processedRow); + await transporter.sendMail(mailOptions); + res.json({ message: 'Código enviado.' }); } catch (error) { + console.error('Register error:', error); res.status(500).json({ error: error.message }); } }); -// --- Rotas de Tenants (Super Admin) --- -app.get('/api/tenants', async (req, res) => { - try { - const query = ` - SELECT t.*, - (SELECT COUNT(*) FROM users u WHERE u.tenant_id = t.id) as user_count, - (SELECT COUNT(*) FROM attendances a WHERE a.tenant_id = t.id) as attendance_count - FROM tenants t - `; - const [rows] = await pool.query(query); - res.json(rows); - } catch (error) { - res.status(500).json({ error: error.message }); - } -}); - -// Criar Tenant -app.post('/api/tenants', async (req, res) => { - const { name, slug, admin_email, status } = req.body; - const crypto = require('crypto'); - +// Verify +apiRouter.post('/auth/verify', async (req, res) => { + const { email, code } = req.body; const connection = await pool.getConnection(); try { await connection.beginTransaction(); + const [pending] = await connection.query( + 'SELECT * FROM pending_registrations WHERE email = ? AND verification_code = ? AND expires_at > NOW() ORDER BY created_at DESC LIMIT 1', + [email, code] + ); + if (pending.length === 0) return res.status(400).json({ error: 'Código inválido.' }); + const data = pending[0]; const tenantId = `tenant_${crypto.randomUUID().split('-')[0]}`; - - // 1. Criar Tenant - await connection.query( - 'INSERT INTO tenants (id, name, slug, admin_email, status) VALUES (?, ?, ?, ?, ?)', - [tenantId, name, slug, admin_email, status || 'active'] - ); - - // 2. Criar Usuário Admin Default const userId = `u_${crypto.randomUUID().split('-')[0]}`; - await connection.query( - 'INSERT INTO users (id, tenant_id, name, email, role, status) VALUES (?, ?, ?, ?, ?, ?)', - [userId, tenantId, 'Admin', admin_email, 'admin', 'active'] - ); + + await connection.query('INSERT INTO tenants (id, name, slug, admin_email) VALUES (?, ?, ?, ?)', + [tenantId, data.organization_name, data.organization_name.toLowerCase().replace(/ /g, '-'), email]); + await connection.query('INSERT INTO users (id, tenant_id, name, email, password_hash, role) VALUES (?, ?, ?, ?, ?, ?)', + [userId, tenantId, data.full_name, email, data.password_hash, 'owner']); + await connection.query('DELETE FROM pending_registrations WHERE email = ?', [email]); await connection.commit(); - res.status(201).json({ message: 'Tenant created successfully', id: tenantId }); + res.json({ message: 'Sucesso.' }); } catch (error) { await connection.rollback(); - console.error('Erro ao criar tenant:', error); res.status(500).json({ error: error.message }); } finally { connection.release(); } }); +// Login +apiRouter.post('/auth/login', async (req, res) => { + const { email, password } = req.body; + try { + const [users] = await pool.query('SELECT * FROM users WHERE email = ?', [email]); + if (users.length === 0) return res.status(401).json({ error: 'Credenciais inválidas.' }); + + const user = users[0]; -// Serve index.html for any unknown routes (for client-side routing) + // Verificar se o usuário está ativo + if (user.status !== 'active') { + return res.status(403).json({ error: 'Sua conta está inativa. Contate o administrador.' }); + } + + const valid = await bcrypt.compare(password, user.password_hash); + if (!valid) return res.status(401).json({ error: 'Credenciais inválidas.' }); + + const token = jwt.sign({ id: user.id, tenant_id: user.tenant_id, role: user.role }, JWT_SECRET, { expiresIn: '24h' }); + res.json({ token, user: { id: user.id, name: user.name, email: user.email, role: user.role, tenant_id: user.tenant_id } }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Forgot Password +apiRouter.post('/auth/forgot-password', async (req, res) => { + const { email } = req.body; + try { + const [users] = await pool.query('SELECT name FROM users WHERE email = ?', [email]); + if (users.length > 0) { + const token = crypto.randomBytes(32).toString('hex'); + await pool.query('INSERT INTO password_resets (email, token, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 1 HOUR))', [email, token]); + const link = `${process.env.APP_URL || 'http://localhost:3001'}/#/reset-password?token=${token}`; + + const mailOptions = { + from: `"Fasto" <${process.env.MAIL_FROM || 'nao-responda@blyzer.com.br'}>`, + to: email, + subject: 'Recuperação de Senha - Fasto', + html: ` +
+

Olá, ${users[0].name}!

+

Você solicitou a recuperação de senha da sua conta no Fasto.

+

Clique no botão abaixo para criar uma nova senha:

+
+ Redefinir Minha Senha +
+

Este link expira em 1 hora. Se você não solicitou isso, pode ignorar este e-mail.

+
+

Desenvolvido por Blyzer

+
+
+ ` + }; + await transporter.sendMail(mailOptions); + } + res.json({ message: 'Se o e-mail existir, enviamos as instruções.' }); + } catch (error) { + console.error('Forgot password error:', error); + res.status(500).json({ error: error.message }); + } +}); + +// Reset Password +apiRouter.post('/auth/reset-password', async (req, res) => { + const { token, password } = req.body; + try { + const [resets] = await pool.query('SELECT email FROM password_resets WHERE token = ? AND expires_at > NOW()', [token]); + if (resets.length === 0) return res.status(400).json({ error: 'Token inválido.' }); + const hash = await bcrypt.hash(password, 10); + await pool.query('UPDATE users SET password_hash = ? WHERE email = ?', [hash, resets[0].email]); + await pool.query('DELETE FROM password_resets WHERE email = ?', [resets[0].email]); + res.json({ message: 'Senha resetada.' }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// --- User Routes --- +apiRouter.get('/users', async (req, res) => { + try { + const { tenantId } = req.query; + let q = 'SELECT * FROM users'; + const params = []; + if (tenantId && tenantId !== 'all') { q += ' WHERE tenant_id = ?'; params.push(tenantId); } + const [rows] = await pool.query(q, params); + res.json(rows); + } catch (error) { res.status(500).json({ error: error.message }); } +}); + +apiRouter.get('/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({ error: 'Not found' }); + res.json(rows[0]); + } catch (error) { res.status(500).json({ error: error.message }); } +}); + +// Convidar Novo Membro (Admin criando usuário) +apiRouter.post('/users', async (req, res) => { + const { name, email, role, team_id, tenant_id } = req.body; + console.log('--- User Creation Request ---'); + console.log('Body:', req.body); + try { + // 1. Verificar se e-mail já existe + const [existing] = await pool.query('SELECT id FROM users WHERE email = ?', [email]); + if (existing.length > 0) return res.status(400).json({ error: 'E-mail já cadastrado.' }); + + const uid = `u_${crypto.randomUUID().split('-')[0]}`; + const placeholderHash = 'pending_setup'; // Usuário não pode logar com isso + + // 2. Criar Usuário + await pool.query( + 'INSERT INTO users (id, tenant_id, team_id, name, email, password_hash, role, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + [uid, tenant_id, team_id || null, name, email, placeholderHash, role || 'agent', 'active'] + ); + + // 3. Gerar Token de Setup de Senha (reusando lógica de reset) + const token = crypto.randomBytes(32).toString('hex'); + await pool.query( + 'INSERT INTO password_resets (email, token, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 24 HOUR))', + [email, token] + ); + + // 4. Enviar E-mail de Boas-vindas + const setupLink = `${process.env.APP_URL || 'http://localhost:3001'}/#/reset-password?token=${token}`; + + await transporter.sendMail({ + from: `"Fasto" <${process.env.MAIL_FROM || 'nao-responda@blyzer.com.br'}>`, + to: email, + subject: 'Bem-vindo ao Fasto - Crie sua senha', + html: ` +
+

Olá, ${name}!

+

Você foi convidado para participar da equipe no Fasto.

+

Clique no botão abaixo para definir sua senha e acessar sua conta:

+
+ Definir Minha Senha +
+

Este link expira em 24 horas. Se você não esperava este convite, ignore este e-mail.

+
+

Desenvolvido por Blyzer

+
+
+ ` + }); + + res.status(201).json({ id: uid, message: 'Convite enviado com sucesso.' }); + } catch (error) { + console.error('Invite error:', error); + res.status(500).json({ error: error.message }); + } +}); + +apiRouter.put('/users/:id', async (req, res) => { + const { name, bio, role, team_id, status } = req.body; + try { + await pool.query( + 'UPDATE users SET name = ?, bio = ?, role = ?, team_id = ?, status = ? WHERE id = ?', + [name, bio, role, team_id || null, status, req.params.id] + ); + res.json({ message: 'User updated successfully.' }); + } catch (error) { + console.error('Update user error:', error); + res.status(500).json({ error: error.message }); + } +}); + +apiRouter.delete('/users/:id', async (req, res) => { + try { + await pool.query('DELETE FROM users WHERE id = ?', [req.params.id]); + res.json({ message: 'User deleted successfully.' }); + } catch (error) { + console.error('Delete user error:', error); + res.status(500).json({ error: error.message }); + } +}); + + +// --- Attendance Routes --- +apiRouter.get('/attendances', async (req, res) => { + try { + const { tenantId, userId, teamId, startDate, endDate } = req.query; + let q = 'SELECT a.*, u.team_id FROM attendances a JOIN users u ON a.user_id = u.id WHERE a.tenant_id = ?'; + const params = [tenantId]; + if (startDate && endDate) { q += ' AND a.created_at BETWEEN ? AND ?'; params.push(new Date(startDate), new Date(endDate)); } + if (userId && userId !== 'all') { q += ' AND a.user_id = ?'; params.push(userId); } + if (teamId && teamId !== 'all') { q += ' AND u.team_id = ?'; params.push(teamId); } + q += ' ORDER BY a.created_at DESC'; + const [rows] = await pool.query(q, params); + const processed = rows.map(r => ({ + ...r, + attention_points: typeof r.attention_points === 'string' ? JSON.parse(r.attention_points) : r.attention_points, + improvement_points: typeof r.improvement_points === 'string' ? JSON.parse(r.improvement_points) : r.improvement_points, + converted: Boolean(r.converted) + })); + res.json(processed); + } catch (error) { res.status(500).json({ error: error.message }); } +}); + +apiRouter.get('/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({ error: 'Not found' }); + const r = rows[0]; + res.json({ + ...r, + attention_points: typeof r.attention_points === 'string' ? JSON.parse(r.attention_points) : r.attention_points, + improvement_points: typeof r.improvement_points === 'string' ? JSON.parse(r.improvement_points) : r.improvement_points, + converted: Boolean(r.converted) + }); + } catch (error) { res.status(500).json({ error: error.message }); } +}); + +// --- Tenant Routes --- +apiRouter.get('/tenants', async (req, res) => { + try { + const q = 'SELECT t.*, (SELECT COUNT(*) FROM users u WHERE u.tenant_id = t.id) as user_count, (SELECT COUNT(*) FROM attendances a WHERE a.tenant_id = t.id) as attendance_count FROM tenants t'; + const [rows] = await pool.query(q); + res.json(rows); + } catch (error) { res.status(500).json({ error: error.message }); } +}); + +// --- Team Routes --- +apiRouter.get('/teams', async (req, res) => { + try { + const { tenantId } = req.query; + const [rows] = await pool.query('SELECT * FROM teams WHERE tenant_id = ?', [tenantId]); + res.json(rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +apiRouter.post('/teams', async (req, res) => { + const { name, description, tenantId } = req.body; + try { + const tid = `team_${crypto.randomUUID().split('-')[0]}`; + await pool.query( + 'INSERT INTO teams (id, tenant_id, name, description) VALUES (?, ?, ?, ?)', + [tid, tenantId, name, description || null] + ); + res.status(201).json({ id: tid, message: 'Time criado com sucesso.' }); + } catch (error) { + console.error('Create team error:', error); + res.status(500).json({ error: error.message }); + } +}); + +apiRouter.put('/teams/:id', async (req, res) => { + const { name, description } = req.body; + try { + await pool.query( + 'UPDATE teams SET name = ?, description = ? WHERE id = ?', + [name, description || null, req.params.id] + ); + res.json({ message: 'Team updated successfully.' }); + } catch (error) { + console.error('Update team error:', error); + res.status(500).json({ error: error.message }); + } +}); + + + +apiRouter.post('/tenants', async (req, res) => { + const { name, slug, admin_email, status } = req.body; + const connection = await pool.getConnection(); + try { + await connection.beginTransaction(); + const tid = `tenant_${crypto.randomUUID().split('-')[0]}`; + await connection.query('INSERT INTO tenants (id, name, slug, admin_email, status) VALUES (?, ?, ?, ?, ?)', [tid, name, slug, admin_email, status || 'active']); + const uid = `u_${crypto.randomUUID().split('-')[0]}`; + await connection.query('INSERT INTO users (id, tenant_id, name, email, role) VALUES (?, ?, ?, ?, ?)', [uid, tid, 'Admin', admin_email, 'admin']); + await connection.commit(); + res.status(201).json({ id: tid }); + } catch (error) { await connection.rollback(); res.status(500).json({ error: error.message }); } finally { connection.release(); } +}); + +// Mount the API Router +app.use('/api', apiRouter); + +// Serve static files if (process.env.NODE_ENV === 'production') { + app.use(express.static(path.join(__dirname, '../dist'))); app.get('*', (req, res) => { + // Avoid hijacking API requests + if (req.url.startsWith('/api')) return res.status(404).json({ error: 'API route not found' }); res.sendFile(path.join(__dirname, '../dist/index.html')); }); } diff --git a/components/Layout.tsx b/components/Layout.tsx index 833ceff..0df1c03 100644 --- a/components/Layout.tsx +++ b/components/Layout.tsx @@ -57,7 +57,8 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => // 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('/admin/users')) return 'Membros'; + if (location.pathname.includes('/admin/teams')) return 'Times'; 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'; @@ -96,7 +97,8 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {!isSuperAdmin && ( <> - + + )} diff --git a/constants.ts b/constants.ts index a8216d8..4b464e7 100644 --- a/constants.ts +++ b/constants.ts @@ -1,8 +1,6 @@ import { Attendance, FunnelStage, Tenant, User } from './types'; -export const CURRENT_TENANT_ID = 'tenant_123'; - export const TENANTS: Tenant[] = [ { id: 'tenant_123', diff --git a/docker-compose.local.yml b/docker-compose.local.yml index b452985..dbdc7e1 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -11,8 +11,14 @@ services: - DB_USER=${DB_USER:-root} - DB_PASSWORD=${DB_PASSWORD:-root_password} - DB_NAME=${DB_NAME:-agenciac_comia} + - SMTP_HOST=${SMTP_HOST} + - SMTP_PORT=${SMTP_PORT} + - SMTP_USER=${SMTP_USER} + - SMTP_PASS=${SMTP_PASS} + - MAIL_FROM=${MAIL_FROM} volumes: - ./dist:/app/dist # Map local build to container + - ./backend:/app/backend # Map backend source to container depends_on: - db diff --git a/index.html b/index.html index aa3a4b5..fd4b08f 100644 --- a/index.html +++ b/index.html @@ -3,43 +3,17 @@ - CTMS | Commercial Team Management + Fasto | Management - - - +
- - - \ No newline at end of file + + + diff --git a/package-lock.json b/package-lock.json index d59130e..8d4660f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,16 +8,21 @@ "name": "ctms---commercial-team-management-system", "version": "0.0.0", "dependencies": { + "bcryptjs": "^3.0.3", "cors": "^2.8.5", "express": "^4.18.2", + "jsonwebtoken": "^9.0.3", "lucide-react": "^0.574.0", "mysql2": "^3.9.1", + "nodemailer": "^8.0.1", "react": "^19.2.4", "react-dom": "^19.2.4", "react-router-dom": "^7.13.0", "recharts": "^3.7.0" }, "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^22.14.0", "@vitejs/plugin-react": "^5.0.0", "typescript": "~5.8.2", @@ -1248,6 +1253,13 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", @@ -1318,6 +1330,24 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.19.12", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.12.tgz", @@ -1395,6 +1425,15 @@ "node": ">=6.0.0" } }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/body-parser": { "version": "1.20.4", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", @@ -1468,6 +1507,12 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1783,6 +1828,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -2269,6 +2323,103 @@ "node": ">=6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", @@ -2469,6 +2620,15 @@ "dev": true, "license": "MIT" }, + "node_modules/nodemailer": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz", + "integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", diff --git a/package.json b/package.json index 912faf2..ff0b1a8 100644 --- a/package.json +++ b/package.json @@ -9,16 +9,21 @@ "preview": "vite preview" }, "dependencies": { + "bcryptjs": "^3.0.3", + "cors": "^2.8.5", + "express": "^4.18.2", + "jsonwebtoken": "^9.0.3", + "lucide-react": "^0.574.0", + "mysql2": "^3.9.1", + "nodemailer": "^8.0.1", "react": "^19.2.4", "react-dom": "^19.2.4", "react-router-dom": "^7.13.0", - "lucide-react": "^0.574.0", - "recharts": "^3.7.0", - "express": "^4.18.2", - "cors": "^2.8.5", - "mysql2": "^3.9.1" + "recharts": "^3.7.0" }, "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^22.14.0", "@vitejs/plugin-react": "^5.0.0", "typescript": "~5.8.2", diff --git a/pages/Dashboard.tsx b/pages/Dashboard.tsx index 25bd0c2..d619d61 100644 --- a/pages/Dashboard.tsx +++ b/pages/Dashboard.tsx @@ -5,8 +5,8 @@ import { import { Users, Clock, Phone, TrendingUp, Filter } from 'lucide-react'; -import { getAttendances, getUsers } from '../services/dataService'; -import { CURRENT_TENANT_ID, COLORS } from '../constants'; +import { getAttendances, getUsers, getTeams } from '../services/dataService'; +import { COLORS } from '../constants'; import { Attendance, DashboardFilter, FunnelStage, User } from '../types'; import { KPICard } from '../components/KPICard'; import { DateRangePicker } from '../components/DateRangePicker'; @@ -26,6 +26,7 @@ export const Dashboard: React.FC = () => { const [loading, setLoading] = useState(true); const [data, setData] = useState([]); const [users, setUsers] = useState([]); + const [teams, setTeams] = useState([]); const [filters, setFilters] = useState({ dateRange: { @@ -40,14 +41,19 @@ export const Dashboard: React.FC = () => { 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) + const tenantId = localStorage.getItem('ctms_tenant_id'); + if (!tenantId) return; + + // Fetch users, attendances and teams in parallel + const [fetchedUsers, fetchedData, fetchedTeams] = await Promise.all([ + getUsers(tenantId), + getAttendances(tenantId, filters), + getTeams(tenantId) ]); setUsers(fetchedUsers); setData(fetchedData); + setTeams(fetchedTeams); } catch (error) { console.error("Error loading dashboard data:", error); } finally { @@ -158,7 +164,7 @@ export const Dashboard: React.FC = () => { }; if (loading && data.length === 0) { - return
Carregando Dashboard...
; + return
Carregando Dashboard...
; } return ( @@ -191,8 +197,7 @@ export const Dashboard: React.FC = () => { onChange={(e) => handleFilterChange('teamId', e.target.value)} > - - + {teams.map(t => )} @@ -311,4 +316,4 @@ export const Dashboard: React.FC = () => { ); -}; \ No newline at end of file +}; diff --git a/pages/ForgotPassword.tsx b/pages/ForgotPassword.tsx new file mode 100644 index 0000000..cf0fe7c --- /dev/null +++ b/pages/ForgotPassword.tsx @@ -0,0 +1,136 @@ +import React, { useState } from 'react'; +import { useNavigate, Link } from 'react-router-dom'; +import { Hexagon, Mail, ArrowRight, Loader2, ArrowLeft, CheckCircle2 } from 'lucide-react'; +import { forgotPassword } from '../services/dataService'; + +export const ForgotPassword: React.FC = () => { + const [isLoading, setIsLoading] = useState(false); + const [email, setEmail] = useState(''); + const [error, setError] = useState(''); + const [isSuccess, setIsSuccess] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + setError(''); + + try { + await forgotPassword(email); + setIsSuccess(true); + } catch (err: any) { + setError(err.message || 'Erro ao processar solicitação.'); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+
+
+ +
+ Fasto. +
+

+ Recupere sua senha +

+

+ Digite seu e-mail e enviaremos as instruções. +

+
+ +
+
+ + {isSuccess ? ( +
+
+
+ +
+
+

E-mail enviado!

+

+ Se o e-mail {email} estiver cadastrado, você receberá um link em instantes. +

+
+ + Voltar para o login + +
+
+ ) : ( +
+
+ +
+
+ +
+ setEmail(e.target.value)} + className="block w-full pl-10 pr-3 py-2 border border-slate-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-500 sm:text-sm transition-all" + placeholder="voce@empresa.com" + /> +
+
+ + {error && ( +
+ {error} +
+ )} + +
+ +
+ +
+ + Voltar para o login + +
+
+ )} + +
+
+
+
+
+
+ + Desenvolvido por Blyzer + +
+
+
+
+
+
+ ); +}; diff --git a/pages/Login.tsx b/pages/Login.tsx index c4d8ab2..028328b 100644 --- a/pages/Login.tsx +++ b/pages/Login.tsx @@ -1,56 +1,62 @@ import React, { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { Hexagon, Lock, Mail, ArrowRight, Loader2, Info } from 'lucide-react'; -import { getUsers } from '../services/dataService'; +import { useNavigate, Link } from 'react-router-dom'; +import { Hexagon, Lock, Mail, ArrowRight, Loader2, Eye, EyeOff, AlertCircle } from 'lucide-react'; +import { login } from '../services/dataService'; 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 [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); const [error, setError] = useState(''); + const [emailError, setEmailError] = useState(''); + + const validateEmail = (value: string) => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!value) { + setEmailError(''); + } else if (!emailRegex.test(value)) { + setEmailError('Por favor, insira um e-mail válido.'); + } else { + setEmailError(''); + } + }; + + const handleEmailChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setEmail(value); + validateEmail(value); + }; const handleLogin = async (e: React.FormEvent) => { e.preventDefault(); + if (emailError) return; + setIsLoading(true); setError(''); try { - // Fetch all users to find match (simplified auth for demo) - const users = await getUsers('all'); - const user = users.find(u => u.email.toLowerCase() === email.toLowerCase()); - - if (user) { - localStorage.setItem('ctms_user_id', user.id); - localStorage.setItem('ctms_tenant_id', user.tenant_id || ''); - - setIsLoading(false); - - if (user.role === 'super_admin') { - navigate('/super-admin'); - } else { - navigate('/'); - } + const data = await login({ email, password }); + + localStorage.setItem('ctms_token', data.token); + localStorage.setItem('ctms_user_id', data.user.id); + localStorage.setItem('ctms_tenant_id', data.user.tenant_id || ''); + + setIsLoading(false); + + if (data.user.role === 'super_admin') { + navigate('/super-admin'); } else { - setIsLoading(false); - setError('Usuário não encontrado.'); + navigate('/'); } - } catch (err) { + } catch (err: any) { console.error("Login error:", err); setIsLoading(false); - setError('Erro ao conectar ao servidor.'); + setError(err.message || 'E-mail ou senha incorretos.'); } }; - const fillCredentials = (type: 'admin' | 'super') => { - if (type === 'admin') { - setEmail('lidya@fasto.com'); - } else { - setEmail('root@system.com'); - } - setPassword('password'); - }; - return (
@@ -63,29 +69,11 @@ export const Login: React.FC = () => {

Acesse sua conta

-

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

- {/* Demo Helper - Remove in production */} -
-
- Dicas de Acesso (Demo): -
-
- - -
-
-
- +
{ autoComplete="email" required value={email} - onChange={(e) => 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" + onChange={handleEmailChange} + className={`block w-full pl-10 pr-3 py-2 border rounded-lg leading-5 bg-white placeholder-slate-400 focus:outline-none focus:ring-2 transition-all sm:text-sm ${ + emailError + ? 'border-red-300 focus:ring-red-100 focus:border-red-500' + : 'border-slate-300 focus:ring-blue-100 focus:border-blue-500' + }`} placeholder="voce@empresa.com" />
+ {emailError && ( +

+ {emailError} +

+ )}
-
{error && ( -
+
+ {error}
)} @@ -151,16 +156,16 @@ export const Login: React.FC = () => {
@@ -191,4 +198,4 @@ export const Login: React.FC = () => {
); -}; \ No newline at end of file +}; diff --git a/pages/Register.tsx b/pages/Register.tsx new file mode 100644 index 0000000..2527aa7 --- /dev/null +++ b/pages/Register.tsx @@ -0,0 +1,180 @@ +import React, { useState } from 'react'; +import { useNavigate, Link } from 'react-router-dom'; +import { Hexagon, Lock, Mail, ArrowRight, Loader2, User, Building } from 'lucide-react'; +import { register } from '../services/dataService'; + +export const Register: React.FC = () => { + const navigate = useNavigate(); + const [isLoading, setIsLoading] = useState(false); + const [formData, setFormData] = useState({ + name: '', + email: '', + password: '', + organizationName: '' + }); + const [error, setError] = useState(''); + + const handleRegister = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + setError(''); + + try { + const success = await register(formData); + if (success) { + // Save email to localStorage for the verification step + localStorage.setItem('pending_verify_email', formData.email); + navigate('/verify'); + } + } catch (err: any) { + setError(err.message || 'Erro ao realizar registro.'); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+
+
+ +
+ Fasto. +
+

+ Crie sua conta +

+

+ Já tem uma conta? Faça login agora +

+
+ +
+
+ + +
+ +
+
+ +
+ setFormData({...formData, name: e.target.value})} + className="block w-full pl-10 pr-3 py-2 border border-slate-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-500 sm:text-sm" + placeholder="Seu nome" + /> +
+
+ +
+ +
+
+ +
+ setFormData({...formData, organizationName: e.target.value})} + className="block w-full pl-10 pr-3 py-2 border border-slate-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-500 sm:text-sm" + placeholder="Ex: Minha Empresa" + /> +
+
+ +
+ +
+
+ +
+ setFormData({...formData, email: e.target.value})} + className="block w-full pl-10 pr-3 py-2 border border-slate-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-500 sm:text-sm" + placeholder="voce@empresa.com" + /> +
+
+ +
+ +
+
+ +
+ setFormData({...formData, password: e.target.value})} + className="block w-full pl-10 pr-3 py-2 border border-slate-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-500 sm:text-sm" + placeholder="••••••••" + /> +
+
+ + {error && ( +
+ {error} +
+ )} + +
+ +
+ + +
+
+
+
+
+
+ + Desenvolvido por Blyzer + +
+
+
+
+
+
+ ); +}; diff --git a/pages/ResetPassword.tsx b/pages/ResetPassword.tsx new file mode 100644 index 0000000..4eb8d44 --- /dev/null +++ b/pages/ResetPassword.tsx @@ -0,0 +1,172 @@ +import React, { useState, useEffect } from 'react'; +import { useNavigate, useLocation, Link } from 'react-router-dom'; +import { Hexagon, Lock, ArrowRight, Loader2, CheckCircle2, AlertCircle } from 'lucide-react'; +import { resetPassword } from '../services/dataService'; + +export const ResetPassword: React.FC = () => { + const navigate = useNavigate(); + const location = useLocation(); + const [isLoading, setIsLoading] = useState(false); + const [password, setPassword] = useState(''); + const [confirmPassword, setPasswordConfirm] = useState(''); + const [error, setError] = useState(''); + const [isSuccess, setIsSuccess] = useState(false); + const [token, setToken] = useState(''); + + useEffect(() => { + const params = new URLSearchParams(location.search); + const t = params.get('token'); + if (!t) { + setError('Token de recuperação ausente.'); + } else { + setToken(t); + } + }, [location]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (password !== confirmPassword) { + setError('As senhas não coincidem.'); + return; + } + + setIsLoading(true); + setError(''); + + try { + await resetPassword(password, token); + setIsSuccess(true); + setTimeout(() => navigate('/login'), 3000); + } catch (err: any) { + setError(err.message || 'Erro ao redefinir senha.'); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+
+
+ +
+ Fasto. +
+

+ Nova senha +

+

+ Escolha uma senha forte para sua segurança. +

+
+ +
+
+ + {isSuccess ? ( +
+
+
+ +
+
+

Sucesso!

+

+ Sua senha foi redefinida. Redirecionando para o login... +

+
+ ) : ( +
+ {!token && ( +
+ Link inválido ou expirado. +
+ )} + +
+ +
+
+ +
+ setPassword(e.target.value)} + className="block w-full pl-10 pr-3 py-2 border border-slate-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-500 sm:text-sm transition-all" + placeholder="••••••••" + /> +
+
+ +
+ +
+
+ +
+ setPasswordConfirm(e.target.value)} + className="block w-full pl-10 pr-3 py-2 border border-slate-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-500 sm:text-sm transition-all" + placeholder="••••••••" + /> +
+
+ + {error && ( +
+ {error} +
+ )} + +
+ +
+
+ )} + +
+
+
+
+
+
+ + Desenvolvido por Blyzer + +
+
+
+
+
+
+ ); +}; diff --git a/pages/TeamManagement.tsx b/pages/TeamManagement.tsx index ff314b7..7aabb62 100644 --- a/pages/TeamManagement.tsx +++ b/pages/TeamManagement.tsx @@ -1,285 +1,153 @@ -import React, { useState } from 'react'; -import { Users, Plus, MoreHorizontal, Mail, Shield, Search, X, Edit, Trash2, Save } from 'lucide-react'; -import { USERS } from '../constants'; +import React, { useState, useEffect } from 'react'; +import { Users, Plus, Mail, Search, X, Edit, Trash2, Loader2, CheckCircle2, AlertCircle, AlertTriangle } from 'lucide-react'; +import { getUsers, getTeams, createMember, deleteUser, updateUser, getUserById } from '../services/dataService'; 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 [users, setUsers] = useState([]); + const [teams, setTeams] = useState([]); + const [loading, setLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [searchTerm, setSearchTerm] = useState(''); - - // State for handling Add/Edit + const [currentUser, setCurrentUser] = useState(null); + const [userToDelete, setUserToDelete] = useState(null); + const [deleteConfirmName, setDeleteConfirmName] = useState(''); 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 [formData, setFormData] = useState({ name: '', email: '', role: 'agent' as any, team_id: '', status: 'active' as any }); - const filteredUsers = users.filter(u => - u.name.toLowerCase().includes(searchTerm.toLowerCase()) || - u.email.toLowerCase().includes(searchTerm.toLowerCase()) - ); + const loadData = async () => { + const tid = localStorage.getItem('ctms_tenant_id'); + const uid = localStorage.getItem('ctms_user_id'); + if (!tid) return; + setLoading(true); + try { + const [fu, ft, me] = await Promise.all([getUsers(tid), getTeams(tid), uid ? getUserById(uid) : null]); + setUsers(fu.filter(u => u.role !== 'super_admin')); + setTeams(ft); + if (me) setCurrentUser(me); + } catch (err) { console.error(err); } finally { setLoading(false); } + }; + + useEffect(() => { loadData(); }, []); + + const handleSave = async (e: React.FormEvent) => { + e.preventDefault(); + setIsSaving(true); + try { + const tid = localStorage.getItem('ctms_tenant_id') || ''; + const success = editingUser ? await updateUser(editingUser.id, formData) : await createMember({ ...formData, tenant_id: tid }); + if (success) { setIsModalOpen(false); loadData(); } + } catch (err) { console.error(err); } finally { setIsSaving(false); } + }; + + const handleConfirmDelete = async () => { + if (!userToDelete || deleteConfirmName !== userToDelete.name) return; + setIsSaving(true); + try { + if (await deleteUser(userToDelete.id)) { setIsDeleteModalOpen(false); loadData(); } + } catch (err) { console.error(err); } finally { setIsSaving(false); } + }; + + if (loading && users.length === 0) return
Carregando...
; + + const canManage = currentUser?.role === 'admin' || currentUser?.role === 'super_admin'; + const filtered = 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.

+

Membros

+

Gerencie os acessos da sua organização.

- + {canManage && ( + + )}
- {/* Toolbar */} -
-
- - setSearchTerm(e.target.value)} - /> -
-
- {filteredUsers.length} membros encontrados -
+
+ setSearchTerm(e.target.value)} className="w-full max-w-md border border-slate-200 p-2 rounded-lg text-sm outline-none" />
- - {/* Table */} -
- - - - - - - - +
UsuárioFunçãoTimeStatusAções
+ + + + + + + {canManage && } + + + + {filtered.map(user => ( + + + + + + {canManage && ( + + )} - - - {filteredUsers.map((user) => ( - - - - - - - - ))} - {filteredUsers.length === 0 && ( - - - - )} - -
UsuárioFunçãoTimeStatusAções
+
+ +
{user.name}
{user.email}
+
+
{user.role}{teams.find(t => t.id === user.team_id)?.name || '-'}{user.status} + + +
-
-
- - -
-
-
{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})} - /> -
- -
-
- - -
- -
- - -
-
- -
- - -
- -
- - -
+ {isModalOpen && ( +
+
+

{editingUser ? 'Editar' : 'Novo'} Membro

+ +
setFormData({...formData, name:e.target.value})} className="w-full border p-2 rounded-lg" required />
+
setFormData({...formData, email:e.target.value})} className="w-full border p-2 rounded-lg" disabled={!!editingUser} required />
+
+
+
+
+
+
+ + +
- )} + )} + + {isDeleteModalOpen && userToDelete && ( +
+
+

Excluir {userToDelete.name}?

+ setDeleteConfirmName(e.target.value)} className="w-full border-2 p-2 rounded-lg mb-4 text-center font-bold" placeholder="Digite o nome para confirmar" /> +
+ + +
+
+
+ )}
); -}; \ No newline at end of file +}; diff --git a/pages/Teams.tsx b/pages/Teams.tsx new file mode 100644 index 0000000..04cca1b --- /dev/null +++ b/pages/Teams.tsx @@ -0,0 +1,117 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { Building2, Users, Plus, Search, Target, ArrowUpRight, Loader2, CheckCircle2, AlertCircle, Edit2, X } from 'lucide-react'; +import { getTeams, getUsers, getAttendances, createTeam, updateTeam } from '../services/dataService'; +import { User, Attendance } from '../types'; + +export const Teams: React.FC = () => { + const [teams, setTeams] = useState([]); + const [users, setUsers] = useState([]); + const [attendances, setAttendances] = useState([]); + const [loading, setLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [isModalOpen, setIsModalOpen] = useState(false); + const [editingTeam, setEditingTeam] = useState(null); + const [searchTerm, setSearchTerm] = useState(''); + const [toast, setToast] = useState<{message: string, type: 'success' | 'error'} | null>(null); + const [formData, setFormData] = useState({ name: '', description: '' }); + + const showToast = (message: string, type: 'success' | 'error') => { + setToast({ message, type }); + setTimeout(() => setToast(null), 4000); + }; + + const loadData = async () => { + const tid = localStorage.getItem('ctms_tenant_id'); + if (!tid) return; + setLoading(true); + try { + const [ft, fu, fa] = await Promise.all([ + getTeams(tid), + getUsers(tid), + getAttendances(tid, { dateRange: { start: new Date(0), end: new Date() }, userId: 'all', teamId: 'all' }) + ]); + setTeams(ft); setUsers(fu); setAttendances(fa); + } catch (e) { console.error(e); } finally { setLoading(false); } + }; + + useEffect(() => { loadData(); }, []); + + const stats = useMemo(() => teams.map(t => { + const tu = users.filter(u => u.team_id === t.id); + const ta = attendances.filter(a => tu.some(u => u.id === a.user_id)); + const wins = ta.filter(a => a.converted).length; + const rate = ta.length > 0 ? (wins / ta.length) * 100 : 0; + return { ...t, memberCount: tu.length, leads: ta.length, rate: rate.toFixed(1) }; + }), [teams, users, attendances]); + + const filtered = stats.filter(t => t.name.toLowerCase().includes(searchTerm.toLowerCase())); + + const handleSave = async (e: React.FormEvent) => { + e.preventDefault(); + setIsSaving(true); + try { + const tid = localStorage.getItem('ctms_tenant_id') || ''; + if (editingTeam) { + if (await updateTeam(editingTeam.id, formData)) { showToast('Atualizado!', 'success'); setIsModalOpen(false); loadData(); } + } else { + if (await createTeam({ ...formData, tenantId: tid })) { showToast('Criado!', 'success'); setIsModalOpen(false); loadData(); } + } + } catch (e) { showToast('Erro', 'error'); } finally { setIsSaving(false); } + }; + + if (loading && teams.length === 0) return
Carregando...
; + + return ( +
+
+
+

Times

+

Desempenho por grupo.

+
+ +
+ +
+ {filtered.map(t => ( +
+
+
+ +
+

{t.name}

+

{t.description || 'Sem descrição'}

+
+
Membros{t.memberCount}
+
Conversão{t.rate}%
+
+
+ ))} +
+ + {isModalOpen && ( +
+
+

{editingTeam ? 'Editar' : 'Novo'} Time

+
+ setFormData({...formData, name:e.target.value})} className="w-full border p-2 rounded-lg" required /> +