feat: replace mock system with real backend, RBAC, and Teams management
All checks were successful
Build and Deploy / build-and-push (push) Successful in 2m3s
All checks were successful
Build and Deploy / build-and-push (push) Successful in 2m3s
- Implemented real JWT authentication and persistent user sessions - Replaced all hardcoded mock data with dynamic MySQL-backed API calls - Created new 'Times' (Teams) dashboard with performance metrics - Renamed 'Equipe' to 'Membros' and centralized team management - Added Role-Based Access Control (RBAC) for Admin/Manager/Agent roles - Implemented secure invite-only member creation and password setup flow - Enhanced Login with password visibility and real-time validation - Added safe delete confirmation modal and custom Toast notifications
This commit is contained in:
12
.env.example
12
.env.example
@@ -3,9 +3,11 @@ DB_HOST=db
|
|||||||
DB_USER=root
|
DB_USER=root
|
||||||
DB_PASSWORD=root_password
|
DB_PASSWORD=root_password
|
||||||
DB_NAME=agenciac_comia
|
DB_NAME=agenciac_comia
|
||||||
|
JWT_SECRET=your_jwt_secret_here
|
||||||
|
|
||||||
# Gitea Runner Configuration
|
# Mailer Configuration
|
||||||
GITEA_INSTANCE_URL=https://gitea.blyzer.com.br
|
SMTP_HOST=mail.blyzer.com.br
|
||||||
GITEA_RUNNER_REGISTRATION_TOKEN=your_token_here
|
SMTP_PORT=587
|
||||||
GITEA_RUNNER_NAME=fasto-runner
|
SMTP_USER=nao-responda@blyzer.com.br
|
||||||
GITEA_RUNNER_LABELS=ubuntu-latest:docker://node:16-bullseye
|
SMTP_PASS=your_smtp_password_here
|
||||||
|
MAIL_FROM=nao-responda@blyzer.com.br
|
||||||
|
|||||||
75
App.tsx
75
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 { HashRouter as Router, Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
||||||
import { Layout } from './components/Layout';
|
import { Layout } from './components/Layout';
|
||||||
import { Dashboard } from './pages/Dashboard';
|
import { Dashboard } from './pages/Dashboard';
|
||||||
@@ -6,15 +6,52 @@ import { UserDetail } from './pages/UserDetail';
|
|||||||
import { AttendanceDetail } from './pages/AttendanceDetail';
|
import { AttendanceDetail } from './pages/AttendanceDetail';
|
||||||
import { SuperAdmin } from './pages/SuperAdmin';
|
import { SuperAdmin } from './pages/SuperAdmin';
|
||||||
import { TeamManagement } from './pages/TeamManagement';
|
import { TeamManagement } from './pages/TeamManagement';
|
||||||
|
import { Teams } from './pages/Teams';
|
||||||
import { Login } from './pages/Login';
|
import { Login } from './pages/Login';
|
||||||
|
import { ForgotPassword } from './pages/ForgotPassword';
|
||||||
|
import { ResetPassword } from './pages/ResetPassword';
|
||||||
import { UserProfile } from './pages/UserProfile';
|
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<User | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isLoginPage = location.pathname === '/login';
|
|
||||||
|
|
||||||
if (isLoginPage) {
|
useEffect(() => {
|
||||||
return <>{children}</>;
|
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 <div className="flex h-screen items-center justify-center bg-slate-50 text-slate-400">Carregando...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Layout>{children}</Layout>;
|
return <Layout>{children}</Layout>;
|
||||||
@@ -23,21 +60,21 @@ const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<Router>
|
<Router>
|
||||||
<AppLayout>
|
<Routes>
|
||||||
<Routes>
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/reset-password" element={<ResetPassword />} />
|
||||||
<Route path="/users" element={<Navigate to="/admin/users" replace />} />
|
<Route path="/" element={<AuthGuard><Dashboard /></AuthGuard>} />
|
||||||
<Route path="/admin/users" element={<TeamManagement />} />
|
<Route path="/admin/users" element={<AuthGuard><TeamManagement /></AuthGuard>} />
|
||||||
<Route path="/users/:id" element={<UserDetail />} />
|
<Route path="/admin/teams" element={<AuthGuard><Teams /></AuthGuard>} />
|
||||||
<Route path="/attendances/:id" element={<AttendanceDetail />} />
|
<Route path="/users/:id" element={<AuthGuard><UserDetail /></AuthGuard>} />
|
||||||
<Route path="/super-admin" element={<SuperAdmin />} />
|
<Route path="/attendances/:id" element={<AuthGuard><AttendanceDetail /></AuthGuard>} />
|
||||||
<Route path="/profile" element={<UserProfile />} />
|
<Route path="/super-admin" element={<AuthGuard><SuperAdmin /></AuthGuard>} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="/profile" element={<AuthGuard><UserProfile /></AuthGuard>} />
|
||||||
</Routes>
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</AppLayout>
|
</Routes>
|
||||||
</Router>
|
</Router>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
509
backend/index.js
509
backend/index.js
@@ -1,196 +1,401 @@
|
|||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const path = require('path');
|
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 pool = require('./db');
|
||||||
|
|
||||||
const app = express();
|
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());
|
app.use(express.json());
|
||||||
|
|
||||||
// Serve static files from the React app
|
// Logger de Requisições
|
||||||
if (process.env.NODE_ENV === 'production') {
|
app.use((req, res, next) => {
|
||||||
app.use(express.static(path.join(__dirname, '../dist')));
|
console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
|
||||||
}
|
next();
|
||||||
|
|
||||||
// --- 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
|
// --- API Router ---
|
||||||
app.get('/api/users/:id', async (req, res) => {
|
const apiRouter = express.Router();
|
||||||
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 });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Atualizar Usuário
|
// --- Auth Routes ---
|
||||||
app.put('/api/users/:id', async (req, res) => {
|
|
||||||
const { name, bio } = req.body;
|
// Register
|
||||||
|
apiRouter.post('/auth/register', async (req, res) => {
|
||||||
|
const { name, email, password, organizationName } = req.body;
|
||||||
try {
|
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(
|
await pool.query(
|
||||||
'UPDATE users SET name = ?, bio = ? WHERE id = ?',
|
'INSERT INTO pending_registrations (email, password_hash, full_name, organization_name, verification_code, expires_at) VALUES (?, ?, ?, ?, ?, ?)',
|
||||||
[name, bio, req.params.id]
|
[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 });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
const mailOptions = {
|
||||||
// --- Rotas de Atendimentos ---
|
from: `"Fasto" <${process.env.MAIL_FROM || 'nao-responda@blyzer.com.br'}>`,
|
||||||
|
to: email,
|
||||||
// Listar Atendimentos (Dashboard)
|
subject: 'Seu código de verificação Fasto',
|
||||||
app.get('/api/attendances', async (req, res) => {
|
text: `Olá ${name}, seu código de verificação é: ${verificationCode}`,
|
||||||
try {
|
html: `
|
||||||
const { tenantId, userId, teamId, startDate, endDate } = req.query;
|
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; border-radius: 12px;">
|
||||||
|
<h2 style="color: #0f172a;">Bem-vindo ao Fasto!</h2>
|
||||||
let query = `
|
<p style="color: #475569;">Seu código: <strong>${verificationCode}</strong></p>
|
||||||
SELECT a.*, u.team_id
|
</div>`
|
||||||
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)
|
|
||||||
};
|
};
|
||||||
|
await transporter.sendMail(mailOptions);
|
||||||
res.json(processedRow);
|
res.json({ message: 'Código enviado.' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Register error:', error);
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Rotas de Tenants (Super Admin) ---
|
// Verify
|
||||||
app.get('/api/tenants', async (req, res) => {
|
apiRouter.post('/auth/verify', async (req, res) => {
|
||||||
try {
|
const { email, code } = req.body;
|
||||||
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');
|
|
||||||
|
|
||||||
const connection = await pool.getConnection();
|
const connection = await pool.getConnection();
|
||||||
try {
|
try {
|
||||||
await connection.beginTransaction();
|
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]}`;
|
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]}`;
|
const userId = `u_${crypto.randomUUID().split('-')[0]}`;
|
||||||
await connection.query(
|
|
||||||
'INSERT INTO users (id, tenant_id, name, email, role, status) VALUES (?, ?, ?, ?, ?, ?)',
|
await connection.query('INSERT INTO tenants (id, name, slug, admin_email) VALUES (?, ?, ?, ?)',
|
||||||
[userId, tenantId, 'Admin', admin_email, 'admin', 'active']
|
[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();
|
await connection.commit();
|
||||||
res.status(201).json({ message: 'Tenant created successfully', id: tenantId });
|
res.json({ message: 'Sucesso.' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await connection.rollback();
|
await connection.rollback();
|
||||||
console.error('Erro ao criar tenant:', error);
|
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
} finally {
|
} finally {
|
||||||
connection.release();
|
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: `
|
||||||
|
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; border-radius: 12px;">
|
||||||
|
<h2 style="color: #0f172a;">Olá, ${users[0].name}!</h2>
|
||||||
|
<p style="color: #475569;">Você solicitou a recuperação de senha da sua conta no Fasto.</p>
|
||||||
|
<p style="color: #475569;">Clique no botão abaixo para criar uma nova senha:</p>
|
||||||
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="${link}" style="background-color: #0f172a; color: white; padding: 12px 24px; text-decoration: none; border-radius: 8px; font-weight: bold; display: inline-block;">Redefinir Minha Senha</a>
|
||||||
|
</div>
|
||||||
|
<p style="font-size: 12px; color: #94a3b8;">Este link expira em 1 hora. Se você não solicitou isso, pode ignorar este e-mail.</p>
|
||||||
|
<div style="border-top: 1px solid #f1f5f9; margin-top: 20px; padding-top: 20px; text-align: center;">
|
||||||
|
<p style="font-size: 12px; color: #94a3b8;">Desenvolvido por <a href="https://blyzer.com.br" style="color: #3b82f6; text-decoration: none;">Blyzer</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
};
|
||||||
|
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: `
|
||||||
|
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; border-radius: 12px;">
|
||||||
|
<h2 style="color: #0f172a;">Olá, ${name}!</h2>
|
||||||
|
<p style="color: #475569;">Você foi convidado para participar da equipe no Fasto.</p>
|
||||||
|
<p style="color: #475569;">Clique no botão abaixo para definir sua senha e acessar sua conta:</p>
|
||||||
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="${setupLink}" style="background-color: #0f172a; color: white; padding: 12px 24px; text-decoration: none; border-radius: 8px; font-weight: bold; display: inline-block;">Definir Minha Senha</a>
|
||||||
|
</div>
|
||||||
|
<p style="font-size: 12px; color: #94a3b8;">Este link expira em 24 horas. Se você não esperava este convite, ignore este e-mail.</p>
|
||||||
|
<div style="border-top: 1px solid #f1f5f9; margin-top: 20px; padding-top: 20px; text-align: center;">
|
||||||
|
<p style="font-size: 12px; color: #94a3b8;">Desenvolvido por <a href="https://blyzer.com.br" style="color: #3b82f6; text-decoration: none;">Blyzer</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
});
|
||||||
|
|
||||||
|
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') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
app.use(express.static(path.join(__dirname, '../dist')));
|
||||||
app.get('*', (req, res) => {
|
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'));
|
res.sendFile(path.join(__dirname, '../dist/index.html'));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,8 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
|||||||
// Simple title mapping based on route
|
// Simple title mapping based on route
|
||||||
const getPageTitle = () => {
|
const getPageTitle = () => {
|
||||||
if (location.pathname === '/') return 'Dashboard';
|
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('/users/')) return 'Histórico do Usuário';
|
||||||
if (location.pathname.includes('/attendances')) return 'Detalhes do Atendimento';
|
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('/super-admin')) return 'Gestão de Organizações';
|
||||||
@@ -96,7 +97,8 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
|||||||
{!isSuperAdmin && (
|
{!isSuperAdmin && (
|
||||||
<>
|
<>
|
||||||
<SidebarItem to="/" icon={LayoutDashboard} label="Dashboard" collapsed={false} />
|
<SidebarItem to="/" icon={LayoutDashboard} label="Dashboard" collapsed={false} />
|
||||||
<SidebarItem to="/admin/users" icon={Users} label="Equipe" collapsed={false} />
|
<SidebarItem to="/admin/users" icon={Users} label="Membros" collapsed={false} />
|
||||||
|
<SidebarItem to="/admin/teams" icon={Building2} label="Times" collapsed={false} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
|
|
||||||
import { Attendance, FunnelStage, Tenant, User } from './types';
|
import { Attendance, FunnelStage, Tenant, User } from './types';
|
||||||
|
|
||||||
export const CURRENT_TENANT_ID = 'tenant_123';
|
|
||||||
|
|
||||||
export const TENANTS: Tenant[] = [
|
export const TENANTS: Tenant[] = [
|
||||||
{
|
{
|
||||||
id: 'tenant_123',
|
id: 'tenant_123',
|
||||||
|
|||||||
@@ -11,8 +11,14 @@ services:
|
|||||||
- DB_USER=${DB_USER:-root}
|
- DB_USER=${DB_USER:-root}
|
||||||
- DB_PASSWORD=${DB_PASSWORD:-root_password}
|
- DB_PASSWORD=${DB_PASSWORD:-root_password}
|
||||||
- DB_NAME=${DB_NAME:-agenciac_comia}
|
- 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:
|
volumes:
|
||||||
- ./dist:/app/dist # Map local build to container
|
- ./dist:/app/dist # Map local build to container
|
||||||
|
- ./backend:/app/backend # Map backend source to container
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
|
|
||||||
|
|||||||
42
index.html
42
index.html
@@ -3,43 +3,17 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>CTMS | Commercial Team Management</title>
|
<title>Fasto | Management</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
body {
|
body { font-family: 'Inter', sans-serif; background-color: #f8fafc; }
|
||||||
font-family: 'Inter', sans-serif;
|
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||||
background-color: #f8fafc;
|
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
|
||||||
}
|
|
||||||
/* Custom scrollbar for webkit */
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
}
|
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: #cbd5e1;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
<script type="importmap">
|
</head>
|
||||||
{
|
|
||||||
"imports": {
|
|
||||||
"react": "https://esm.sh/react@^19.2.4",
|
|
||||||
"react-dom/": "https://esm.sh/react-dom@^19.2.4/",
|
|
||||||
"react/": "https://esm.sh/react@^19.2.4/",
|
|
||||||
"react-router-dom": "https://esm.sh/react-router-dom@^7.13.0",
|
|
||||||
"lucide-react": "https://esm.sh/lucide-react@^0.574.0",
|
|
||||||
"recharts": "https://esm.sh/recharts@^3.7.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<link rel="stylesheet" href="/index.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/index.tsx"></script>
|
<script type="module" src="/index.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
160
package-lock.json
generated
160
package-lock.json
generated
@@ -8,16 +8,21 @@
|
|||||||
"name": "ctms---commercial-team-management-system",
|
"name": "ctms---commercial-team-management-system",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
"lucide-react": "^0.574.0",
|
"lucide-react": "^0.574.0",
|
||||||
"mysql2": "^3.9.1",
|
"mysql2": "^3.9.1",
|
||||||
|
"nodemailer": "^8.0.1",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-router-dom": "^7.13.0",
|
"react-router-dom": "^7.13.0",
|
||||||
"recharts": "^3.7.0"
|
"recharts": "^3.7.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
"typescript": "~5.8.2",
|
"typescript": "~5.8.2",
|
||||||
@@ -1248,6 +1253,13 @@
|
|||||||
"@babel/types": "^7.28.2"
|
"@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": {
|
"node_modules/@types/d3-array": {
|
||||||
"version": "3.2.2",
|
"version": "3.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||||
@@ -1318,6 +1330,24 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.19.12",
|
"version": "22.19.12",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.12.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.12.tgz",
|
||||||
@@ -1395,6 +1425,15 @@
|
|||||||
"node": ">=6.0.0"
|
"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": {
|
"node_modules/body-parser": {
|
||||||
"version": "1.20.4",
|
"version": "1.20.4",
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
"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": "^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": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
@@ -1783,6 +1828,15 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/ee-first": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||||
@@ -2269,6 +2323,103 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/long": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||||
@@ -2469,6 +2620,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
|
|||||||
15
package.json
15
package.json
@@ -9,16 +9,21 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-router-dom": "^7.13.0",
|
"react-router-dom": "^7.13.0",
|
||||||
"lucide-react": "^0.574.0",
|
"recharts": "^3.7.0"
|
||||||
"recharts": "^3.7.0",
|
|
||||||
"express": "^4.18.2",
|
|
||||||
"cors": "^2.8.5",
|
|
||||||
"mysql2": "^3.9.1"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
"typescript": "~5.8.2",
|
"typescript": "~5.8.2",
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
Users, Clock, Phone, TrendingUp, Filter
|
Users, Clock, Phone, TrendingUp, Filter
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { getAttendances, getUsers } from '../services/dataService';
|
import { getAttendances, getUsers, getTeams } from '../services/dataService';
|
||||||
import { CURRENT_TENANT_ID, COLORS } from '../constants';
|
import { COLORS } from '../constants';
|
||||||
import { Attendance, DashboardFilter, FunnelStage, User } from '../types';
|
import { Attendance, DashboardFilter, FunnelStage, User } from '../types';
|
||||||
import { KPICard } from '../components/KPICard';
|
import { KPICard } from '../components/KPICard';
|
||||||
import { DateRangePicker } from '../components/DateRangePicker';
|
import { DateRangePicker } from '../components/DateRangePicker';
|
||||||
@@ -26,6 +26,7 @@ export const Dashboard: React.FC = () => {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [data, setData] = useState<Attendance[]>([]);
|
const [data, setData] = useState<Attendance[]>([]);
|
||||||
const [users, setUsers] = useState<User[]>([]);
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [teams, setTeams] = useState<any[]>([]);
|
||||||
|
|
||||||
const [filters, setFilters] = useState<DashboardFilter>({
|
const [filters, setFilters] = useState<DashboardFilter>({
|
||||||
dateRange: {
|
dateRange: {
|
||||||
@@ -40,14 +41,19 @@ export const Dashboard: React.FC = () => {
|
|||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// Fetch users and attendances in parallel
|
const tenantId = localStorage.getItem('ctms_tenant_id');
|
||||||
const [fetchedUsers, fetchedData] = await Promise.all([
|
if (!tenantId) return;
|
||||||
getUsers(CURRENT_TENANT_ID),
|
|
||||||
getAttendances(CURRENT_TENANT_ID, filters)
|
// Fetch users, attendances and teams in parallel
|
||||||
|
const [fetchedUsers, fetchedData, fetchedTeams] = await Promise.all([
|
||||||
|
getUsers(tenantId),
|
||||||
|
getAttendances(tenantId, filters),
|
||||||
|
getTeams(tenantId)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setUsers(fetchedUsers);
|
setUsers(fetchedUsers);
|
||||||
setData(fetchedData);
|
setData(fetchedData);
|
||||||
|
setTeams(fetchedTeams);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error loading dashboard data:", error);
|
console.error("Error loading dashboard data:", error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -158,7 +164,7 @@ export const Dashboard: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (loading && data.length === 0) {
|
if (loading && data.length === 0) {
|
||||||
return <div className="flex h-full items-center justify-center text-slate-400">Carregando Dashboard...</div>;
|
return <div className="flex h-full items-center justify-center text-slate-400 p-12">Carregando Dashboard...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -191,8 +197,7 @@ export const Dashboard: React.FC = () => {
|
|||||||
onChange={(e) => handleFilterChange('teamId', e.target.value)}
|
onChange={(e) => handleFilterChange('teamId', e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="all">Todas Equipes</option>
|
<option value="all">Todas Equipes</option>
|
||||||
<option value="sales_1">Vendas Alpha</option>
|
{teams.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
|
||||||
<option value="sales_2">Vendas Beta</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -311,4 +316,4 @@ export const Dashboard: React.FC = () => {
|
|||||||
<ProductLists requested={productStats.requested} sold={productStats.sold} />
|
<ProductLists requested={productStats.requested} sold={productStats.sold} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
136
pages/ForgotPassword.tsx
Normal file
136
pages/ForgotPassword.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="min-h-screen bg-slate-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||||
|
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||||
|
<div className="flex justify-center items-center gap-2 text-slate-900">
|
||||||
|
<div className="bg-slate-900 text-white p-2 rounded-lg">
|
||||||
|
<Hexagon size={28} fill="currentColor" />
|
||||||
|
</div>
|
||||||
|
<span className="text-3xl font-bold tracking-tight">Fasto<span className="text-yellow-500">.</span></span>
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-slate-900">
|
||||||
|
Recupere sua senha
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-center text-sm text-slate-600">
|
||||||
|
Digite seu e-mail e enviaremos as instruções.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||||
|
<div className="bg-white py-8 px-4 shadow-xl shadow-slate-200/50 rounded-2xl sm:px-10 border border-slate-100">
|
||||||
|
|
||||||
|
{isSuccess ? (
|
||||||
|
<div className="text-center space-y-4 py-4">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="bg-green-100 p-3 rounded-full text-green-600">
|
||||||
|
<CheckCircle2 size={32} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold text-slate-900">E-mail enviado!</h3>
|
||||||
|
<p className="text-sm text-slate-600">
|
||||||
|
Se o e-mail <strong>{email}</strong> estiver cadastrado, você receberá um link em instantes.
|
||||||
|
</p>
|
||||||
|
<div className="pt-4">
|
||||||
|
<Link to="/login" className="text-blue-600 font-medium hover:underline flex items-center justify-center gap-2">
|
||||||
|
<ArrowLeft size={16} /> Voltar para o login
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-slate-700">
|
||||||
|
Endereço de e-mail
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 relative rounded-md shadow-sm">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<Mail className="h-5 w-5 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="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 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="text-red-500 text-sm font-medium text-center">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full flex justify-center items-center gap-2 py-2.5 px-4 border border-transparent rounded-lg shadow-sm text-sm font-semibold text-white bg-slate-900 hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-slate-900 transition-all"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="animate-spin h-4 w-4" />
|
||||||
|
Enviando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Enviar Instruções <ArrowRight className="h-4 w-4" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<Link to="/login" className="text-sm text-slate-500 hover:text-slate-800 flex items-center justify-center gap-1">
|
||||||
|
<ArrowLeft size={14} /> Voltar para o login
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div className="w-full border-t border-slate-200" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-sm">
|
||||||
|
<span className="px-2 bg-white text-slate-500 text-xs">
|
||||||
|
Desenvolvido por <a href="https://blyzer.com.br" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">Blyzer</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
133
pages/Login.tsx
133
pages/Login.tsx
@@ -1,56 +1,62 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
import { Hexagon, Lock, Mail, ArrowRight, Loader2, Info } from 'lucide-react';
|
import { Hexagon, Lock, Mail, ArrowRight, Loader2, Eye, EyeOff, AlertCircle } from 'lucide-react';
|
||||||
import { getUsers } from '../services/dataService';
|
import { login } from '../services/dataService';
|
||||||
|
|
||||||
export const Login: React.FC = () => {
|
export const Login: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [email, setEmail] = useState('lidya@fasto.com');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('password');
|
const [password, setPassword] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [error, setError] = useState('');
|
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<HTMLInputElement>) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setEmail(value);
|
||||||
|
validateEmail(value);
|
||||||
|
};
|
||||||
|
|
||||||
const handleLogin = async (e: React.FormEvent) => {
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (emailError) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch all users to find match (simplified auth for demo)
|
const data = await login({ email, password });
|
||||||
const users = await getUsers('all');
|
|
||||||
const user = users.find(u => u.email.toLowerCase() === email.toLowerCase());
|
localStorage.setItem('ctms_token', data.token);
|
||||||
|
localStorage.setItem('ctms_user_id', data.user.id);
|
||||||
if (user) {
|
localStorage.setItem('ctms_tenant_id', data.user.tenant_id || '');
|
||||||
localStorage.setItem('ctms_user_id', user.id);
|
|
||||||
localStorage.setItem('ctms_tenant_id', user.tenant_id || '');
|
setIsLoading(false);
|
||||||
|
|
||||||
setIsLoading(false);
|
if (data.user.role === 'super_admin') {
|
||||||
|
navigate('/super-admin');
|
||||||
if (user.role === 'super_admin') {
|
|
||||||
navigate('/super-admin');
|
|
||||||
} else {
|
|
||||||
navigate('/');
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
setIsLoading(false);
|
navigate('/');
|
||||||
setError('Usuário não encontrado.');
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
console.error("Login error:", err);
|
console.error("Login error:", err);
|
||||||
setIsLoading(false);
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
<div className="min-h-screen bg-slate-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||||
@@ -63,29 +69,11 @@ export const Login: React.FC = () => {
|
|||||||
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-slate-900">
|
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-slate-900">
|
||||||
Acesse sua conta
|
Acesse sua conta
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-2 text-center text-sm text-slate-600">
|
|
||||||
Ou <a href="#" className="font-medium text-blue-600 hover:text-blue-500">inicie seu teste grátis de 14 dias</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||||
<div className="bg-white py-8 px-4 shadow-xl shadow-slate-200/50 rounded-2xl sm:px-10 border border-slate-100">
|
<div className="bg-white py-8 px-4 shadow-xl shadow-slate-200/50 rounded-2xl sm:px-10 border border-slate-100">
|
||||||
|
|
||||||
{/* Demo Helper - Remove in production */}
|
|
||||||
<div className="mb-6 p-3 bg-blue-50 border border-blue-100 rounded-lg text-xs text-blue-700">
|
|
||||||
<div className="flex items-center gap-2 font-bold mb-2">
|
|
||||||
<Info size={14} /> Dicas de Acesso (Demo):
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button onClick={() => fillCredentials('admin')} className="px-2 py-1 bg-white border border-blue-200 rounded shadow-sm hover:bg-blue-100 transition">
|
|
||||||
Admin da Empresa
|
|
||||||
</button>
|
|
||||||
<button onClick={() => fillCredentials('super')} className="px-2 py-1 bg-white border border-blue-200 rounded shadow-sm hover:bg-blue-100 transition">
|
|
||||||
Super Admin
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form className="space-y-6" onSubmit={handleLogin}>
|
<form className="space-y-6" onSubmit={handleLogin}>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-slate-700">
|
<label htmlFor="email" className="block text-sm font-medium text-slate-700">
|
||||||
@@ -93,7 +81,7 @@ export const Login: React.FC = () => {
|
|||||||
</label>
|
</label>
|
||||||
<div className="mt-1 relative rounded-md shadow-sm">
|
<div className="mt-1 relative rounded-md shadow-sm">
|
||||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
<Mail className="h-5 w-5 text-slate-400" />
|
<Mail className={`h-5 w-5 ${emailError ? 'text-red-400' : 'text-slate-400'}`} />
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
id="email"
|
id="email"
|
||||||
@@ -102,15 +90,24 @@ export const Login: React.FC = () => {
|
|||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
required
|
required
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={handleEmailChange}
|
||||||
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"
|
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"
|
placeholder="voce@empresa.com"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{emailError && (
|
||||||
|
<p className="mt-1.5 text-xs text-red-500 font-medium flex items-center gap-1">
|
||||||
|
<AlertCircle size={12} /> {emailError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="password" className="block text-sm font-medium text-slate-700">
|
<label htmlFor="password" senior-admin-password className="block text-sm font-medium text-slate-700">
|
||||||
Senha
|
Senha
|
||||||
</label>
|
</label>
|
||||||
<div className="mt-1 relative rounded-md shadow-sm">
|
<div className="mt-1 relative rounded-md shadow-sm">
|
||||||
@@ -120,19 +117,27 @@ export const Login: React.FC = () => {
|
|||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
name="password"
|
name="password"
|
||||||
type="password"
|
type={showPassword ? 'text' : 'password'}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
required
|
required
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => 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"
|
className="block w-full pl-10 pr-10 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="••••••••"
|
placeholder="••••••••"
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute inset-y-0 right-0 pr-3 flex items-center text-slate-400 hover:text-slate-600 transition-colors"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="text-red-500 text-sm font-medium text-center">
|
<div className="bg-red-50 border border-red-100 text-red-600 px-4 py-3 rounded-xl text-sm font-medium flex items-center gap-2 animate-in fade-in slide-in-from-top-2 duration-200">
|
||||||
|
<AlertCircle size={18} className="shrink-0" />
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -151,16 +156,16 @@ export const Login: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<a href="#" className="font-medium text-blue-600 hover:text-blue-500">
|
<Link to="/forgot-password" title="Recuperar Senha" className="font-medium text-blue-600 hover:text-blue-500">
|
||||||
Esqueceu sua senha?
|
Esqueceu sua senha?
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading}
|
disabled={isLoading || !!emailError || !email}
|
||||||
className="w-full flex justify-center items-center gap-2 py-2.5 px-4 border border-transparent rounded-lg shadow-sm text-sm font-semibold text-white bg-slate-900 hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-slate-900 disabled:opacity-70 disabled:cursor-not-allowed transition-all"
|
className="w-full flex justify-center items-center gap-2 py-2.5 px-4 border border-transparent rounded-lg shadow-sm text-sm font-semibold text-white bg-slate-900 hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-slate-900 disabled:opacity-70 disabled:cursor-not-allowed transition-all"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -183,7 +188,9 @@ export const Login: React.FC = () => {
|
|||||||
<div className="w-full border-t border-slate-200" />
|
<div className="w-full border-t border-slate-200" />
|
||||||
</div>
|
</div>
|
||||||
<div className="relative flex justify-center text-sm">
|
<div className="relative flex justify-center text-sm">
|
||||||
<span className="px-2 bg-white text-slate-500">Protegido por SSO Corporativo</span>
|
<span className="px-2 bg-white text-slate-500 text-xs">
|
||||||
|
Desenvolvido por <a href="https://blyzer.com.br" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">Blyzer</a>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -191,4 +198,4 @@ export const Login: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
180
pages/Register.tsx
Normal file
180
pages/Register.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="min-h-screen bg-slate-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||||
|
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||||
|
<div className="flex justify-center items-center gap-2 text-slate-900">
|
||||||
|
<div className="bg-slate-900 text-white p-2 rounded-lg">
|
||||||
|
<Hexagon size={28} fill="currentColor" />
|
||||||
|
</div>
|
||||||
|
<span className="text-3xl font-bold tracking-tight">Fasto<span className="text-yellow-500">.</span></span>
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-slate-900">
|
||||||
|
Crie sua conta
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-center text-sm text-slate-600">
|
||||||
|
Já tem uma conta? <Link to="/login" className="font-medium text-blue-600 hover:text-blue-500">Faça login agora</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||||
|
<div className="bg-white py-8 px-4 shadow-xl shadow-slate-200/50 rounded-2xl sm:px-10 border border-slate-100">
|
||||||
|
|
||||||
|
<form className="space-y-5" onSubmit={handleRegister}>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="name" className="block text-sm font-medium text-slate-700">
|
||||||
|
Nome Completo
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 relative rounded-md shadow-sm">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<User className="h-5 w-5 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="organization" className="block text-sm font-medium text-slate-700">
|
||||||
|
Nome da Organização
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 relative rounded-md shadow-sm">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<Building className="h-5 w-5 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="organization"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={formData.organizationName}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-slate-700">
|
||||||
|
Endereço de e-mail
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 relative rounded-md shadow-sm">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<Mail className="h-5 w-5 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
value={formData.email}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" senior-admin-password className="block text-sm font-medium text-slate-700">
|
||||||
|
Senha
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 relative rounded-md shadow-sm">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<Lock className="h-5 w-5 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
value={formData.password}
|
||||||
|
onChange={(e) => 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="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="text-red-500 text-sm font-medium text-center">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full flex justify-center items-center gap-2 py-2.5 px-4 border border-transparent rounded-lg shadow-sm text-sm font-semibold text-white bg-slate-900 hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-slate-900 transition-all"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="animate-spin h-4 w-4" />
|
||||||
|
Criando conta...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Registrar <ArrowRight className="h-4 w-4" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div className="w-full border-t border-slate-200" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-sm">
|
||||||
|
<span className="px-2 bg-white text-slate-500 text-xs">
|
||||||
|
Desenvolvido por <a href="https://blyzer.com.br" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">Blyzer</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
172
pages/ResetPassword.tsx
Normal file
172
pages/ResetPassword.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="min-h-screen bg-slate-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||||
|
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||||
|
<div className="flex justify-center items-center gap-2 text-slate-900">
|
||||||
|
<div className="bg-slate-900 text-white p-2 rounded-lg">
|
||||||
|
<Hexagon size={28} fill="currentColor" />
|
||||||
|
</div>
|
||||||
|
<span className="text-3xl font-bold tracking-tight">Fasto<span className="text-yellow-500">.</span></span>
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-slate-900">
|
||||||
|
Nova senha
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-center text-sm text-slate-600">
|
||||||
|
Escolha uma senha forte para sua segurança.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||||
|
<div className="bg-white py-8 px-4 shadow-xl shadow-slate-200/50 rounded-2xl sm:px-10 border border-slate-100">
|
||||||
|
|
||||||
|
{isSuccess ? (
|
||||||
|
<div className="text-center space-y-4 py-4">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="bg-green-100 p-3 rounded-full text-green-600">
|
||||||
|
<CheckCircle2 size={32} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold text-slate-900">Sucesso!</h3>
|
||||||
|
<p className="text-sm text-slate-600">
|
||||||
|
Sua senha foi redefinida. Redirecionando para o login...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form className="space-y-5" onSubmit={handleSubmit}>
|
||||||
|
{!token && (
|
||||||
|
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm flex items-center gap-2">
|
||||||
|
<AlertCircle size={18} /> Link inválido ou expirado.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" senior-admin-password className="block text-sm font-medium text-slate-700">
|
||||||
|
Nova Senha
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 relative rounded-md shadow-sm">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<Lock className="h-5 w-5 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
disabled={!token}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => 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="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="confirmPassword" senior-admin-password className="block text-sm font-medium text-slate-700">
|
||||||
|
Confirmar Nova Senha
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 relative rounded-md shadow-sm">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<Lock className="h-5 w-5 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
disabled={!token}
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => 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="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="text-red-500 text-sm font-medium text-center">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading || !token}
|
||||||
|
className="w-full flex justify-center items-center gap-2 py-2.5 px-4 border border-transparent rounded-lg shadow-sm text-sm font-semibold text-white bg-slate-900 hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-slate-900 transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="animate-spin h-4 w-4" />
|
||||||
|
Alterando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Redefinir Senha <ArrowRight className="h-4 w-4" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div className="w-full border-t border-slate-200" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-sm">
|
||||||
|
<span className="px-2 bg-white text-slate-500 text-xs">
|
||||||
|
Desenvolvido por <a href="https://blyzer.com.br" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">Blyzer</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,285 +1,153 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Users, Plus, MoreHorizontal, Mail, Shield, Search, X, Edit, Trash2, Save } from 'lucide-react';
|
import { Users, Plus, Mail, Search, X, Edit, Trash2, Loader2, CheckCircle2, AlertCircle, AlertTriangle } from 'lucide-react';
|
||||||
import { USERS } from '../constants';
|
import { getUsers, getTeams, createMember, deleteUser, updateUser, getUserById } from '../services/dataService';
|
||||||
import { User } from '../types';
|
import { User } from '../types';
|
||||||
|
|
||||||
export const TeamManagement: React.FC = () => {
|
export const TeamManagement: React.FC = () => {
|
||||||
const [users, setUsers] = useState<User[]>(USERS.filter(u => u.role !== 'super_admin')); // Default hide super admin from list
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [teams, setTeams] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||||
// State for handling Add/Edit
|
const [userToDelete, setUserToDelete] = useState<User | null>(null);
|
||||||
|
const [deleteConfirmName, setDeleteConfirmName] = useState('');
|
||||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({ name: '', email: '', role: 'agent' as any, team_id: '', status: 'active' as any });
|
||||||
name: '',
|
|
||||||
email: '',
|
|
||||||
role: 'agent' as 'super_admin' | 'admin' | 'manager' | 'agent',
|
|
||||||
team_id: 'sales_1',
|
|
||||||
status: 'active' as 'active' | 'inactive'
|
|
||||||
});
|
|
||||||
|
|
||||||
const filteredUsers = users.filter(u =>
|
const loadData = async () => {
|
||||||
u.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
const tid = localStorage.getItem('ctms_tenant_id');
|
||||||
u.email.toLowerCase().includes(searchTerm.toLowerCase())
|
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 <div className="flex h-screen items-center justify-center text-slate-400">Carregando...</div>;
|
||||||
|
|
||||||
|
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) => {
|
const getRoleBadge = (role: string) => {
|
||||||
switch (role) {
|
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 'admin': return 'bg-purple-100 text-purple-700 border-purple-200';
|
||||||
case 'manager': return 'bg-blue-100 text-blue-700 border-blue-200';
|
case 'manager': return 'bg-blue-100 text-blue-700 border-blue-200';
|
||||||
default: return 'bg-slate-100 text-slate-700 border-slate-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 (
|
return (
|
||||||
<div className="space-y-8 max-w-7xl mx-auto">
|
<div className="space-y-8 max-w-7xl mx-auto pb-12">
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
<div className="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-slate-900 tracking-tight">Gerenciamento de Equipe</h1>
|
<h1 className="text-2xl font-bold text-slate-900 tracking-tight">Membros</h1>
|
||||||
<p className="text-slate-500 text-sm">Gerencie acesso, funções e times de vendas da sua organização.</p>
|
<p className="text-slate-500 text-sm">Gerencie os acessos da sua organização.</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
{canManage && (
|
||||||
onClick={openAddModal}
|
<button onClick={() => { setEditingUser(null); setFormData({name:'', email:'', role:'agent', team_id:'', status:'active'}); setIsModalOpen(true); }} className="bg-slate-900 text-white px-4 py-2 rounded-lg flex items-center gap-2 text-sm font-bold shadow-sm">
|
||||||
className="flex items-center gap-2 bg-slate-900 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-slate-800 transition-colors shadow-sm"
|
<Plus size={16} /> Adicionar Membro
|
||||||
>
|
</button>
|
||||||
<Plus size={16} /> Adicionar Membro
|
)}
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden">
|
<div className="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden">
|
||||||
{/* Toolbar */}
|
<div className="p-4 border-b border-slate-100 bg-slate-50/30">
|
||||||
<div className="p-4 border-b border-slate-100 flex items-center justify-between gap-4">
|
<input type="text" placeholder="Buscar membros..." value={searchTerm} onChange={e => setSearchTerm(e.target.value)} className="w-full max-w-md border border-slate-200 p-2 rounded-lg text-sm outline-none" />
|
||||||
<div className="relative w-full max-w-sm">
|
|
||||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Buscar por nome ou e-mail..."
|
|
||||||
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"
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-slate-500 hidden sm:block">
|
|
||||||
{filteredUsers.length} membros encontrados
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<table className="w-full text-left">
|
||||||
{/* Table */}
|
<thead className="bg-slate-50/50 text-slate-500 text-xs uppercase font-bold border-b">
|
||||||
<div className="overflow-x-auto">
|
<tr>
|
||||||
<table className="w-full text-left border-collapse">
|
<th className="px-6 py-4">Usuário</th>
|
||||||
<thead>
|
<th className="px-6 py-4">Função</th>
|
||||||
<tr className="bg-slate-50/50 text-slate-500 text-xs uppercase tracking-wider border-b border-slate-100">
|
<th className="px-6 py-4">Time</th>
|
||||||
<th className="px-6 py-4 font-semibold">Usuário</th>
|
<th className="px-6 py-4">Status</th>
|
||||||
<th className="px-6 py-4 font-semibold">Função</th>
|
{canManage && <th className="px-6 py-4 text-right">Ações</th>}
|
||||||
<th className="px-6 py-4 font-semibold">Time</th>
|
</tr>
|
||||||
<th className="px-6 py-4 font-semibold">Status</th>
|
</thead>
|
||||||
<th className="px-6 py-4 font-semibold text-right">Ações</th>
|
<tbody className="divide-y text-sm">
|
||||||
|
{filtered.map(user => (
|
||||||
|
<tr key={user.id} className="hover:bg-slate-50 group">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<img src={`https://ui-avatars.com/api/?name=${encodeURIComponent(user.name)}&background=random`} alt="" className="w-8 h-8 rounded-full" />
|
||||||
|
<div><div className="font-bold text-slate-900">{user.name}</div><div className="text-xs text-slate-500">{user.email}</div></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4"><span className={`px-2.5 py-0.5 rounded-full text-xs font-bold border capitalize ${getRoleBadge(user.role)}`}>{user.role}</span></td>
|
||||||
|
<td className="px-6 py-4 text-slate-600">{teams.find(t => t.id === user.team_id)?.name || '-'}</td>
|
||||||
|
<td className="px-6 py-4"><span className={`px-2 py-0.5 rounded-full text-xs font-bold ${user.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-slate-100 text-slate-500'}`}>{user.status}</span></td>
|
||||||
|
{canManage && (
|
||||||
|
<td className="px-6 py-4 text-right flex justify-end gap-2 opacity-0 group-hover:opacity-100">
|
||||||
|
<button onClick={() => { setEditingUser(user); setFormData({name:user.name, email:user.email, role:user.role as any, team_id:user.team_id||'', status:user.status as any}); setIsModalOpen(true); }} className="p-1 hover:text-blue-600"><Edit size={16} /></button>
|
||||||
|
<button onClick={() => { setUserToDelete(user); setDeleteConfirmName(''); setIsDeleteModalOpen(true); }} className="p-1 hover:text-red-600"><Trash2 size={16} /></button>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
))}
|
||||||
<tbody className="divide-y divide-slate-100 text-sm">
|
</tbody>
|
||||||
{filteredUsers.map((user) => (
|
</table>
|
||||||
<tr key={user.id} className="hover:bg-slate-50/50 transition-colors group">
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="relative">
|
|
||||||
<img src={user.avatar_url} alt="" className="w-10 h-10 rounded-full border border-slate-200 object-cover" />
|
|
||||||
<span className={`absolute bottom-0 right-0 w-3 h-3 rounded-full border-2 border-white ${user.status === 'active' ? 'bg-green-500' : 'bg-slate-300'}`}></span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="font-semibold text-slate-900">{user.name}</div>
|
|
||||||
<div className="flex items-center gap-1.5 text-xs text-slate-500">
|
|
||||||
<Mail size={12} /> {user.email}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold border capitalize ${getRoleBadge(user.role)}`}>
|
|
||||||
{user.role === 'manager' ? 'Gerente' : user.role === 'agent' ? 'Agente' : user.role === 'admin' ? 'Admin' : 'Super Admin'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-slate-600 font-medium">
|
|
||||||
{user.team_id === 'sales_1' ? 'Vendas Alpha' : user.team_id === 'sales_2' ? 'Vendas Beta' : '-'}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold border capitalize ${getStatusBadge(user.status)}`}>
|
|
||||||
{user.status === 'active' ? 'Ativo' : 'Inativo'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-right">
|
|
||||||
<div className="flex items-center justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
<button
|
|
||||||
onClick={() => openEditModal(user)}
|
|
||||||
className="p-2 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
|
|
||||||
title="Editar Usuário"
|
|
||||||
>
|
|
||||||
<Edit size={16} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(user.id)}
|
|
||||||
className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
|
||||||
title="Excluir Usuário"
|
|
||||||
>
|
|
||||||
<Trash2 size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{filteredUsers.length === 0 && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={5} className="px-6 py-8 text-center text-slate-400 italic">Nenhum usuário encontrado.</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add/Edit Modal */}
|
{isModalOpen && (
|
||||||
{isModalOpen && (
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/50 backdrop-blur-sm">
|
<div className="bg-white rounded-2xl p-6 w-full max-w-md shadow-xl">
|
||||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-md overflow-hidden animate-in fade-in zoom-in duration-200">
|
<h3 className="text-lg font-bold mb-4">{editingUser ? 'Editar' : 'Novo'} Membro</h3>
|
||||||
<div className="px-6 py-4 border-b border-slate-100 flex justify-between items-center bg-slate-50/50">
|
<form onSubmit={handleSave} className="space-y-4">
|
||||||
<h3 className="text-lg font-bold text-slate-900">{editingUser ? 'Editar Usuário' : 'Convidar Novo Membro'}</h3>
|
<div><label className="text-xs font-bold text-slate-500 block mb-1">NOME</label><input type="text" value={formData.name} onChange={e => setFormData({...formData, name:e.target.value})} className="w-full border p-2 rounded-lg" required /></div>
|
||||||
<button onClick={() => setIsModalOpen(false)} className="text-slate-400 hover:text-slate-600 transition-colors"><X size={20} /></button>
|
<div><label className="text-xs font-bold text-slate-500 block mb-1">E-MAIL</label><input type="email" value={formData.email} onChange={e => setFormData({...formData, email:e.target.value})} className="w-full border p-2 rounded-lg" disabled={!!editingUser} required /></div>
|
||||||
</div>
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div><label className="text-xs font-bold text-slate-500 block mb-1">FUNÇÃO</label><select value={formData.role} onChange={e => setFormData({...formData, role: e.target.value as any})} className="w-full border p-2 rounded-lg"><option value="agent">Agente</option><option value="manager">Gerente</option><option value="admin">Admin</option></select></div>
|
||||||
<form onSubmit={handleSave} className="p-6 space-y-4">
|
<div><label className="text-xs font-bold text-slate-500 block mb-1">TIME</label><select value={formData.team_id} onChange={e => setFormData({...formData, team_id: e.target.value})} className="w-full border p-2 rounded-lg"><option value="">Nenhum</option>{teams.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}</select></div>
|
||||||
<div className="space-y-2">
|
</div>
|
||||||
<label className="text-sm font-medium text-slate-700">Nome Completo</label>
|
<div><label className="text-xs font-bold text-slate-500 block mb-1">STATUS</label><select value={formData.status} onChange={e => setFormData({...formData, status: e.target.value as any})} className="w-full border p-2 rounded-lg"><option value="active">Ativo</option><option value="inactive">Inativo</option></select></div>
|
||||||
<input
|
<div className="flex justify-end gap-2 pt-4">
|
||||||
type="text"
|
<button type="button" onClick={() => setIsModalOpen(false)} className="px-4 py-2">Cancelar</button>
|
||||||
required
|
<button type="submit" className="bg-slate-900 text-white px-6 py-2 rounded-lg font-bold">{isSaving ? '...' : 'Salvar'}</button>
|
||||||
className="w-full px-3 py-2 border border-slate-200 bg-slate-50 rounded-lg text-sm outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400 transition-all"
|
</div>
|
||||||
placeholder="João Silva"
|
|
||||||
value={formData.name}
|
|
||||||
onChange={(e) => setFormData({...formData, name: e.target.value})}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-700">Endereço de E-mail</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
required
|
|
||||||
className="w-full px-3 py-2 border border-slate-200 bg-slate-50 rounded-lg text-sm outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400 transition-all"
|
|
||||||
placeholder="joao@empresa.com"
|
|
||||||
value={formData.email}
|
|
||||||
onChange={(e) => setFormData({...formData, email: e.target.value})}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-700">Função</label>
|
|
||||||
<select
|
|
||||||
className="w-full px-3 py-2 border border-slate-200 bg-slate-50 rounded-lg text-sm outline-none focus:ring-2 focus:ring-blue-100 transition-all cursor-pointer"
|
|
||||||
value={formData.role}
|
|
||||||
onChange={(e) => setFormData({...formData, role: e.target.value as any})}
|
|
||||||
>
|
|
||||||
<option value="agent">Agente</option>
|
|
||||||
<option value="manager">Gerente</option>
|
|
||||||
<option value="admin">Admin</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-700">Time</label>
|
|
||||||
<select
|
|
||||||
className="w-full px-3 py-2 border border-slate-200 bg-slate-50 rounded-lg text-sm outline-none focus:ring-2 focus:ring-blue-100 transition-all cursor-pointer"
|
|
||||||
value={formData.team_id}
|
|
||||||
onChange={(e) => setFormData({...formData, team_id: e.target.value})}
|
|
||||||
>
|
|
||||||
<option value="sales_1">Vendas Alpha</option>
|
|
||||||
<option value="sales_2">Vendas Beta</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-700">Status da Conta</label>
|
|
||||||
<select
|
|
||||||
className="w-full px-3 py-2 border border-slate-200 bg-slate-50 rounded-lg text-sm outline-none focus:ring-2 focus:ring-blue-100 transition-all cursor-pointer"
|
|
||||||
value={formData.status}
|
|
||||||
onChange={(e) => setFormData({...formData, status: e.target.value as any})}
|
|
||||||
>
|
|
||||||
<option value="active">Ativo</option>
|
|
||||||
<option value="inactive">Inativo</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pt-4 flex justify-end gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIsModalOpen(false)}
|
|
||||||
className="px-4 py-2 text-slate-600 hover:bg-slate-100 rounded-lg text-sm font-medium transition-colors"
|
|
||||||
>
|
|
||||||
Cancelar
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg font-medium text-sm hover:bg-slate-800 transition-colors shadow-sm"
|
|
||||||
>
|
|
||||||
<Save size={16} /> {editingUser ? 'Salvar Alterações' : 'Adicionar Membro'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isDeleteModalOpen && userToDelete && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
|
||||||
|
<div className="bg-white rounded-2xl p-6 w-full max-w-md">
|
||||||
|
<h3 className="text-lg font-bold mb-2">Excluir {userToDelete.name}?</h3>
|
||||||
|
<input type="text" value={deleteConfirmName} onChange={e => 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" />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => setIsDeleteModalOpen(false)} className="flex-1 py-2">Cancelar</button>
|
||||||
|
<button onClick={handleConfirmDelete} disabled={deleteConfirmName !== userToDelete.name} className="flex-1 py-2 bg-red-600 text-white rounded-lg">Excluir</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
117
pages/Teams.tsx
Normal file
117
pages/Teams.tsx
Normal file
@@ -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<any[]>([]);
|
||||||
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [attendances, setAttendances] = useState<Attendance[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [editingTeam, setEditingTeam] = useState<any | null>(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 <div className="p-12 text-center text-slate-400">Carregando...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 max-w-7xl mx-auto pb-12">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900">Times</h1>
|
||||||
|
<p className="text-slate-500 text-sm">Desempenho por grupo.</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => { setEditingTeam(null); setFormData({name:'', description:''}); setIsModalOpen(true); }} className="bg-slate-900 text-white px-4 py-2 rounded-lg flex items-center gap-2 text-sm font-bold">
|
||||||
|
<Plus size={16} /> Novo Time
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{filtered.map(t => (
|
||||||
|
<div key={t.id} className="bg-white rounded-2xl border border-slate-200 p-6 shadow-sm group">
|
||||||
|
<div className="flex justify-between mb-4">
|
||||||
|
<div className="p-3 bg-blue-50 text-blue-600 rounded-xl"><Building2 size={24} /></div>
|
||||||
|
<button onClick={() => { setEditingTeam(t); setFormData({name:t.name, description:t.description||''}); setIsModalOpen(true); }} className="text-slate-400 hover:text-blue-600 opacity-0 group-hover:opacity-100 transition-opacity"><Edit2 size={18} /></button>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-slate-900">{t.name}</h3>
|
||||||
|
<p className="text-sm text-slate-500 mb-4">{t.description || 'Sem descrição'}</p>
|
||||||
|
<div className="grid grid-cols-2 gap-4 pt-4 border-t text-sm">
|
||||||
|
<div><span className="text-slate-400 block text-xs font-bold uppercase">Membros</span><strong>{t.memberCount}</strong></div>
|
||||||
|
<div><span className="text-slate-400 block text-xs font-bold uppercase">Conversão</span><strong className="text-blue-600">{t.rate}%</strong></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isModalOpen && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/50 backdrop-blur-sm">
|
||||||
|
<div className="bg-white rounded-2xl p-6 w-full max-w-md">
|
||||||
|
<h3 className="text-lg font-bold mb-4">{editingTeam ? 'Editar' : 'Novo'} Time</h3>
|
||||||
|
<form onSubmit={handleSave} className="space-y-4">
|
||||||
|
<input type="text" placeholder="Nome" value={formData.name} onChange={e => setFormData({...formData, name:e.target.value})} className="w-full border p-2 rounded-lg" required />
|
||||||
|
<textarea placeholder="Descrição" value={formData.description} onChange={e => setFormData({...formData, description:e.target.value})} className="w-full border p-2 rounded-lg resize-none" rows={3} />
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button type="button" onClick={() => setIsModalOpen(false)} className="px-4 py-2 text-slate-500">Cancelar</button>
|
||||||
|
<button type="submit" disabled={isSaving} className="bg-slate-900 text-white px-6 py-2 rounded-lg font-bold">{isSaving ? '...' : 'Salvar'}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{toast && (
|
||||||
|
<div className="fixed bottom-8 right-8 z-[100] flex items-center gap-3 px-6 py-4 rounded-2xl shadow-2xl bg-white border border-slate-100">
|
||||||
|
<CheckCircle2 className="text-green-500" size={20} />
|
||||||
|
<span className="font-bold text-sm text-slate-700">{toast.message}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { useEffect, useState, useMemo } from 'react';
|
import React, { useEffect, useState, useMemo } from 'react';
|
||||||
import { useParams, Link } from 'react-router-dom';
|
import { useParams, Link } from 'react-router-dom';
|
||||||
import { getAttendances, getUserById } from '../services/dataService';
|
import { getAttendances, getUserById } from '../services/dataService';
|
||||||
import { CURRENT_TENANT_ID } from '../constants';
|
|
||||||
import { Attendance, User, FunnelStage } from '../types';
|
import { Attendance, User, FunnelStage } from '../types';
|
||||||
import { ArrowLeft, Mail, Phone, Clock, MessageSquare, ChevronLeft, ChevronRight, Eye } from 'lucide-react';
|
import { ArrowLeft, Mail, Phone, Clock, MessageSquare, ChevronLeft, ChevronRight, Eye } from 'lucide-react';
|
||||||
|
|
||||||
@@ -20,11 +19,12 @@ export const UserDetail: React.FC = () => {
|
|||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
|
const tenantId = localStorage.getItem('ctms_tenant_id');
|
||||||
const u = await getUserById(id);
|
const u = await getUserById(id);
|
||||||
setUser(u);
|
setUser(u);
|
||||||
|
|
||||||
if (u) {
|
if (u && tenantId) {
|
||||||
const data = await getAttendances(CURRENT_TENANT_ID, {
|
const data = await getAttendances(tenantId, {
|
||||||
userId: id,
|
userId: id,
|
||||||
dateRange: { start: new Date(0), end: new Date() } // All time
|
dateRange: { start: new Date(0), end: new Date() } // All time
|
||||||
});
|
});
|
||||||
|
|||||||
116
pages/VerifyCode.tsx
Normal file
116
pages/VerifyCode.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { Hexagon, ShieldCheck, ArrowRight, Loader2 } from 'lucide-react';
|
||||||
|
import { verifyCode } from '../services/dataService';
|
||||||
|
|
||||||
|
export const VerifyCode: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [code, setCode] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const storedEmail = localStorage.getItem('pending_verify_email');
|
||||||
|
if (!storedEmail) {
|
||||||
|
navigate('/register');
|
||||||
|
} else {
|
||||||
|
setEmail(storedEmail);
|
||||||
|
}
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
const handleVerify = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const success = await verifyCode({ email, code });
|
||||||
|
if (success) {
|
||||||
|
localStorage.removeItem('pending_verify_email');
|
||||||
|
alert('Conta verificada com sucesso! Agora você pode fazer login.');
|
||||||
|
navigate('/login');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Código inválido ou expirado.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||||
|
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||||
|
<div className="flex justify-center items-center gap-2 text-slate-900">
|
||||||
|
<div className="bg-slate-900 text-white p-2 rounded-lg">
|
||||||
|
<Hexagon size={28} fill="currentColor" />
|
||||||
|
</div>
|
||||||
|
<span className="text-3xl font-bold tracking-tight">Fasto<span className="text-yellow-500">.</span></span>
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-slate-900">
|
||||||
|
Verifique seu e-mail
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-center text-sm text-slate-600 px-4">
|
||||||
|
Enviamos um código de 6 dígitos para <strong>{email}</strong>. Por favor, insira-o abaixo.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||||
|
<div className="bg-white py-8 px-4 shadow-xl shadow-slate-200/50 rounded-2xl sm:px-10 border border-slate-100">
|
||||||
|
|
||||||
|
<form className="space-y-6" onSubmit={handleVerify}>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="code" className="block text-sm font-medium text-slate-700 text-center mb-4">
|
||||||
|
Código de Verificação
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="code"
|
||||||
|
type="text"
|
||||||
|
maxLength={6}
|
||||||
|
required
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
|
||||||
|
className="block w-full text-center text-3xl tracking-[0.5em] font-bold py-3 border border-slate-300 rounded-lg bg-slate-50 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-500 transition-all"
|
||||||
|
placeholder="000000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="text-red-500 text-sm font-medium text-center">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading || code.length !== 6}
|
||||||
|
className="w-full flex justify-center items-center gap-2 py-2.5 px-4 border border-transparent rounded-lg shadow-sm text-sm font-semibold text-white bg-slate-900 hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-slate-900 disabled:opacity-70 disabled:cursor-not-allowed transition-all"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="animate-spin h-4 w-4" />
|
||||||
|
Verificando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Verificar Código <ShieldCheck className="h-4 w-4" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/register')}
|
||||||
|
className="text-sm text-slate-500 hover:text-slate-800"
|
||||||
|
>
|
||||||
|
Alterar e-mail ou voltar ao registro
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -50,14 +50,19 @@ export const getUserById = async (id: string): Promise<User | undefined> => {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_URL}/users/${id}`);
|
const response = await fetch(`${API_URL}/users/${id}`);
|
||||||
if (!response.ok) return undefined;
|
if (!response.ok) return undefined;
|
||||||
return await response.json();
|
|
||||||
|
const contentType = response.headers.get("content-type");
|
||||||
|
if (contentType && contentType.indexOf("application/json") !== -1) {
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("API Error (getUserById):", error);
|
console.error("API Error (getUserById):", error);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateUser = async (id: string, userData: { name: string, bio: string }): Promise<boolean> => {
|
export const updateUser = async (id: string, userData: any): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_URL}/users/${id}`, {
|
const response = await fetch(`${API_URL}/users/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -71,11 +76,42 @@ export const updateUser = async (id: string, userData: { name: string, bio: stri
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const createMember = async (userData: any): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/users`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(userData)
|
||||||
|
});
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API Error (createMember):", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteUser = async (id: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/users/${id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API Error (deleteUser):", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const getAttendanceById = async (id: string): Promise<Attendance | undefined> => {
|
export const getAttendanceById = async (id: string): Promise<Attendance | undefined> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_URL}/attendances/${id}`);
|
const response = await fetch(`${API_URL}/attendances/${id}`);
|
||||||
if (!response.ok) return undefined;
|
if (!response.ok) return undefined;
|
||||||
return await response.json();
|
|
||||||
|
const contentType = response.headers.get("content-type");
|
||||||
|
if (contentType && contentType.indexOf("application/json") !== -1) {
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("API Error (getAttendanceById):", error);
|
console.error("API Error (getAttendanceById):", error);
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -86,13 +122,57 @@ export const getTenants = async (): Promise<any[]> => {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_URL}/tenants`);
|
const response = await fetch(`${API_URL}/tenants`);
|
||||||
if (!response.ok) throw new Error('Falha ao buscar tenants');
|
if (!response.ok) throw new Error('Falha ao buscar tenants');
|
||||||
return await response.json();
|
|
||||||
|
const contentType = response.headers.get("content-type");
|
||||||
|
if (contentType && contentType.indexOf("application/json") !== -1) {
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
return [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("API Error (getTenants):", error);
|
console.error("API Error (getTenants):", error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getTeams = async (tenantId: string): Promise<any[]> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/teams?tenantId=${tenantId}`);
|
||||||
|
if (!response.ok) throw new Error('Falha ao buscar equipes');
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API Error (getTeams):", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createTeam = async (teamData: any): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/teams`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(teamData)
|
||||||
|
});
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API Error (createTeam):", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateTeam = async (id: string, teamData: any): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/teams/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(teamData)
|
||||||
|
});
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API Error (updateTeam):", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const createTenant = async (tenantData: any): Promise<boolean> => {
|
export const createTenant = async (tenantData: any): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_URL}/tenants`, {
|
const response = await fetch(`${API_URL}/tenants`, {
|
||||||
@@ -106,3 +186,87 @@ export const createTenant = async (tenantData: any): Promise<boolean> => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- Auth Functions ---
|
||||||
|
|
||||||
|
export const login = async (credentials: any): Promise<any> => {
|
||||||
|
const response = await fetch(`${API_URL}/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(credentials)
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentType = response.headers.get("content-type");
|
||||||
|
const isJson = contentType && contentType.indexOf("application/json") !== -1;
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = isJson ? await response.json() : { error: 'Erro no servidor' };
|
||||||
|
throw new Error(error.error || 'Erro no login');
|
||||||
|
}
|
||||||
|
|
||||||
|
return isJson ? await response.json() : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const register = async (userData: any): Promise<boolean> => {
|
||||||
|
const response = await fetch(`${API_URL}/auth/register`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(userData)
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || 'Erro no registro');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const verifyCode = async (data: any): Promise<boolean> => {
|
||||||
|
const response = await fetch(`${API_URL}/auth/verify`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || 'Código inválido ou expirado');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const forgotPassword = async (email: string): Promise<string> => {
|
||||||
|
const response = await fetch(`${API_URL}/auth/forgot-password`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email })
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentType = response.headers.get("content-type");
|
||||||
|
const isJson = contentType && contentType.indexOf("application/json") !== -1;
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = isJson ? await response.json() : { error: 'Erro no servidor' };
|
||||||
|
throw new Error(error.error || 'Erro ao processar solicitação');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = isJson ? await response.json() : { message: 'Solicitação processada' };
|
||||||
|
return data.message;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resetPassword = async (password: string, token: string): Promise<string> => {
|
||||||
|
const response = await fetch(`${API_URL}/auth/reset-password`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password, token })
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentType = response.headers.get("content-type");
|
||||||
|
const isJson = contentType && contentType.indexOf("application/json") !== -1;
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = isJson ? await response.json() : { error: 'Erro no servidor' };
|
||||||
|
throw new Error(error.error || 'Erro ao resetar senha');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = isJson ? await response.json() : { message: 'Senha redefinida' };
|
||||||
|
return data.message;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user