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

- 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:
Cauê Faleiros
2026-03-02 10:26:20 -03:00
parent 76b919d857
commit b7e73fce3d
19 changed files with 1707 additions and 553 deletions

View File

@@ -5,8 +5,8 @@ import {
import {
Users, Clock, Phone, TrendingUp, Filter
} from 'lucide-react';
import { getAttendances, getUsers } from '../services/dataService';
import { CURRENT_TENANT_ID, COLORS } from '../constants';
import { getAttendances, getUsers, getTeams } from '../services/dataService';
import { COLORS } from '../constants';
import { Attendance, DashboardFilter, FunnelStage, User } from '../types';
import { KPICard } from '../components/KPICard';
import { DateRangePicker } from '../components/DateRangePicker';
@@ -26,6 +26,7 @@ export const Dashboard: React.FC = () => {
const [loading, setLoading] = useState(true);
const [data, setData] = useState<Attendance[]>([]);
const [users, setUsers] = useState<User[]>([]);
const [teams, setTeams] = useState<any[]>([]);
const [filters, setFilters] = useState<DashboardFilter>({
dateRange: {
@@ -40,14 +41,19 @@ export const Dashboard: React.FC = () => {
const fetchData = async () => {
setLoading(true);
try {
// Fetch users and attendances in parallel
const [fetchedUsers, fetchedData] = await Promise.all([
getUsers(CURRENT_TENANT_ID),
getAttendances(CURRENT_TENANT_ID, filters)
const tenantId = localStorage.getItem('ctms_tenant_id');
if (!tenantId) return;
// Fetch users, attendances and teams in parallel
const [fetchedUsers, fetchedData, fetchedTeams] = await Promise.all([
getUsers(tenantId),
getAttendances(tenantId, filters),
getTeams(tenantId)
]);
setUsers(fetchedUsers);
setData(fetchedData);
setTeams(fetchedTeams);
} catch (error) {
console.error("Error loading dashboard data:", error);
} finally {
@@ -158,7 +164,7 @@ export const Dashboard: React.FC = () => {
};
if (loading && data.length === 0) {
return <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 (
@@ -191,8 +197,7 @@ export const Dashboard: React.FC = () => {
onChange={(e) => handleFilterChange('teamId', e.target.value)}
>
<option value="all">Todas Equipes</option>
<option value="sales_1">Vendas Alpha</option>
<option value="sales_2">Vendas Beta</option>
{teams.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
</div>
</div>
@@ -311,4 +316,4 @@ export const Dashboard: React.FC = () => {
<ProductLists requested={productStats.requested} sold={productStats.sold} />
</div>
);
};
};

136
pages/ForgotPassword.tsx Normal file
View 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>
);
};

View File

@@ -1,56 +1,62 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Hexagon, Lock, Mail, ArrowRight, Loader2, Info } from 'lucide-react';
import { getUsers } from '../services/dataService';
import { useNavigate, Link } from 'react-router-dom';
import { Hexagon, Lock, Mail, ArrowRight, Loader2, Eye, EyeOff, AlertCircle } from 'lucide-react';
import { login } from '../services/dataService';
export const Login: React.FC = () => {
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const [email, setEmail] = useState('lidya@fasto.com');
const [password, setPassword] = useState('password');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState('');
const [emailError, setEmailError] = useState('');
const validateEmail = (value: string) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!value) {
setEmailError('');
} else if (!emailRegex.test(value)) {
setEmailError('Por favor, insira um e-mail válido.');
} else {
setEmailError('');
}
};
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setEmail(value);
validateEmail(value);
};
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
if (emailError) return;
setIsLoading(true);
setError('');
try {
// Fetch all users to find match (simplified auth for demo)
const users = await getUsers('all');
const user = users.find(u => u.email.toLowerCase() === email.toLowerCase());
if (user) {
localStorage.setItem('ctms_user_id', user.id);
localStorage.setItem('ctms_tenant_id', user.tenant_id || '');
setIsLoading(false);
if (user.role === 'super_admin') {
navigate('/super-admin');
} else {
navigate('/');
}
const data = await login({ email, password });
localStorage.setItem('ctms_token', data.token);
localStorage.setItem('ctms_user_id', data.user.id);
localStorage.setItem('ctms_tenant_id', data.user.tenant_id || '');
setIsLoading(false);
if (data.user.role === 'super_admin') {
navigate('/super-admin');
} else {
setIsLoading(false);
setError('Usuário não encontrado.');
navigate('/');
}
} catch (err) {
} catch (err: any) {
console.error("Login error:", err);
setIsLoading(false);
setError('Erro ao conectar ao servidor.');
setError(err.message || 'E-mail ou senha incorretos.');
}
};
const fillCredentials = (type: 'admin' | 'super') => {
if (type === 'admin') {
setEmail('lidya@fasto.com');
} else {
setEmail('root@system.com');
}
setPassword('password');
};
return (
<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">
@@ -63,29 +69,11 @@ export const Login: React.FC = () => {
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-slate-900">
Acesse sua conta
</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 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">
{/* 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}>
<div>
<label htmlFor="email" className="block text-sm font-medium text-slate-700">
@@ -93,7 +81,7 @@ export const Login: React.FC = () => {
</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" />
<Mail className={`h-5 w-5 ${emailError ? 'text-red-400' : 'text-slate-400'}`} />
</div>
<input
id="email"
@@ -102,15 +90,24 @@ export const Login: React.FC = () => {
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="block w-full pl-10 pr-3 py-2 border border-slate-300 rounded-lg leading-5 bg-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-500 sm:text-sm transition-all"
onChange={handleEmailChange}
className={`block w-full pl-10 pr-3 py-2 border rounded-lg leading-5 bg-white placeholder-slate-400 focus:outline-none focus:ring-2 transition-all sm:text-sm ${
emailError
? 'border-red-300 focus:ring-red-100 focus:border-red-500'
: 'border-slate-300 focus:ring-blue-100 focus:border-blue-500'
}`}
placeholder="voce@empresa.com"
/>
</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>
<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
</label>
<div className="mt-1 relative rounded-md shadow-sm">
@@ -120,19 +117,27 @@ export const Login: React.FC = () => {
<input
id="password"
name="password"
type="password"
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
required
value={password}
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="••••••••"
/>
<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>
{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}
</div>
)}
@@ -151,16 +156,16 @@ export const Login: React.FC = () => {
</div>
<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?
</a>
</Link>
</div>
</div>
<div>
<button
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"
>
{isLoading ? (
@@ -183,7 +188,9 @@ export const Login: React.FC = () => {
<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">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>
@@ -191,4 +198,4 @@ export const Login: React.FC = () => {
</div>
</div>
);
};
};

180
pages/Register.tsx Normal file
View 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">
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
View 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>
);
};

View File

@@ -1,285 +1,153 @@
import React, { useState } from 'react';
import { Users, Plus, MoreHorizontal, Mail, Shield, Search, X, Edit, Trash2, Save } from 'lucide-react';
import { USERS } from '../constants';
import React, { useState, useEffect } from 'react';
import { Users, Plus, Mail, Search, X, Edit, Trash2, Loader2, CheckCircle2, AlertCircle, AlertTriangle } from 'lucide-react';
import { getUsers, getTeams, createMember, deleteUser, updateUser, getUserById } from '../services/dataService';
import { User } from '../types';
export const TeamManagement: React.FC = () => {
const [users, setUsers] = useState<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 [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
// State for handling Add/Edit
const [currentUser, setCurrentUser] = useState<User | null>(null);
const [userToDelete, setUserToDelete] = useState<User | null>(null);
const [deleteConfirmName, setDeleteConfirmName] = useState('');
const [editingUser, setEditingUser] = useState<User | null>(null);
const [formData, setFormData] = useState({
name: '',
email: '',
role: 'agent' as 'super_admin' | 'admin' | 'manager' | 'agent',
team_id: 'sales_1',
status: 'active' as 'active' | 'inactive'
});
const [formData, setFormData] = useState({ name: '', email: '', role: 'agent' as any, team_id: '', status: 'active' as any });
const filteredUsers = users.filter(u =>
u.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
u.email.toLowerCase().includes(searchTerm.toLowerCase())
);
const loadData = async () => {
const tid = localStorage.getItem('ctms_tenant_id');
const uid = localStorage.getItem('ctms_user_id');
if (!tid) return;
setLoading(true);
try {
const [fu, ft, me] = await Promise.all([getUsers(tid), getTeams(tid), uid ? getUserById(uid) : null]);
setUsers(fu.filter(u => u.role !== 'super_admin'));
setTeams(ft);
if (me) setCurrentUser(me);
} catch (err) { console.error(err); } finally { setLoading(false); }
};
useEffect(() => { loadData(); }, []);
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
setIsSaving(true);
try {
const tid = localStorage.getItem('ctms_tenant_id') || '';
const success = editingUser ? await updateUser(editingUser.id, formData) : await createMember({ ...formData, tenant_id: tid });
if (success) { setIsModalOpen(false); loadData(); }
} catch (err) { console.error(err); } finally { setIsSaving(false); }
};
const handleConfirmDelete = async () => {
if (!userToDelete || deleteConfirmName !== userToDelete.name) return;
setIsSaving(true);
try {
if (await deleteUser(userToDelete.id)) { setIsDeleteModalOpen(false); loadData(); }
} catch (err) { console.error(err); } finally { setIsSaving(false); }
};
if (loading && users.length === 0) return <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) => {
switch (role) {
case 'super_admin': return 'bg-slate-900 text-white border-slate-700';
case 'admin': return 'bg-purple-100 text-purple-700 border-purple-200';
case 'manager': return 'bg-blue-100 text-blue-700 border-blue-200';
default: return 'bg-slate-100 text-slate-700 border-slate-200';
}
};
const getStatusBadge = (status: string) => {
if (status === 'active') {
return 'bg-green-100 text-green-700 border-green-200';
}
return 'bg-slate-100 text-slate-500 border-slate-200';
};
// Actions
const handleDelete = (userId: string) => {
if (window.confirm('Tem certeza que deseja excluir este usuário? Esta ação não pode ser desfeita.')) {
setUsers(prev => prev.filter(u => u.id !== userId));
}
};
const openAddModal = () => {
setEditingUser(null);
setFormData({ name: '', email: '', role: 'agent', team_id: 'sales_1', status: 'active' });
setIsModalOpen(true);
};
const openEditModal = (user: User) => {
setEditingUser(user);
setFormData({
name: user.name,
email: user.email,
role: user.role,
team_id: user.team_id,
status: user.status
});
setIsModalOpen(true);
};
const handleSave = (e: React.FormEvent) => {
e.preventDefault();
if (editingUser) {
// Update existing user
setUsers(prev => prev.map(u => u.id === editingUser.id ? { ...u, ...formData } : u));
} else {
// Create new user
const newUser: User = {
id: Date.now().toString(),
tenant_id: 'tenant_123', // Mock default
avatar_url: `https://ui-avatars.com/api/?name=${encodeURIComponent(formData.name)}&background=random`,
...formData
};
setUsers(prev => [...prev, newUser]);
}
setIsModalOpen(false);
};
return (
<div className="space-y-8 max-w-7xl mx-auto">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<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 tracking-tight">Gerenciamento de Equipe</h1>
<p className="text-slate-500 text-sm">Gerencie acesso, funções e times de vendas da sua organização.</p>
<h1 className="text-2xl font-bold text-slate-900 tracking-tight">Membros</h1>
<p className="text-slate-500 text-sm">Gerencie os acessos da sua organização.</p>
</div>
<button
onClick={openAddModal}
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>
{canManage && (
<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">
<Plus size={16} /> Adicionar Membro
</button>
)}
</div>
<div className="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden">
{/* Toolbar */}
<div className="p-4 border-b border-slate-100 flex items-center justify-between gap-4">
<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 className="p-4 border-b border-slate-100 bg-slate-50/30">
<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>
{/* Table */}
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<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 font-semibold">Usuário</th>
<th className="px-6 py-4 font-semibold">Função</th>
<th className="px-6 py-4 font-semibold">Time</th>
<th className="px-6 py-4 font-semibold">Status</th>
<th className="px-6 py-4 font-semibold text-right">Ações</th>
<table className="w-full text-left">
<thead className="bg-slate-50/50 text-slate-500 text-xs uppercase font-bold border-b">
<tr>
<th className="px-6 py-4">Usuário</th>
<th className="px-6 py-4">Função</th>
<th className="px-6 py-4">Time</th>
<th className="px-6 py-4">Status</th>
{canManage && <th className="px-6 py-4 text-right">Ações</th>}
</tr>
</thead>
<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>
</thead>
<tbody className="divide-y divide-slate-100 text-sm">
{filteredUsers.map((user) => (
<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>
))}
</tbody>
</table>
</div>
{/* Add/Edit Modal */}
{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-xl shadow-xl w-full max-w-md overflow-hidden animate-in fade-in zoom-in duration-200">
<div className="px-6 py-4 border-b border-slate-100 flex justify-between items-center bg-slate-50/50">
<h3 className="text-lg font-bold text-slate-900">{editingUser ? 'Editar Usuário' : 'Convidar Novo Membro'}</h3>
<button onClick={() => setIsModalOpen(false)} className="text-slate-400 hover:text-slate-600 transition-colors"><X size={20} /></button>
</div>
<form onSubmit={handleSave} className="p-6 space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700">Nome Completo</label>
<input
type="text"
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="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>
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="bg-white rounded-2xl p-6 w-full max-w-md shadow-xl">
<h3 className="text-lg font-bold mb-4">{editingUser ? 'Editar' : 'Novo'} Membro</h3>
<form onSubmit={handleSave} className="space-y-4">
<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>
<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 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>
<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>
<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>
<div className="flex justify-end gap-2 pt-4">
<button type="button" onClick={() => setIsModalOpen(false)} className="px-4 py-2">Cancelar</button>
<button type="submit" className="bg-slate-900 text-white px-6 py-2 rounded-lg font-bold">{isSaving ? '...' : 'Salvar'}</button>
</div>
</form>
</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>
);
};
};

117
pages/Teams.tsx Normal file
View 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>
);
};

View File

@@ -1,7 +1,6 @@
import React, { useEffect, useState, useMemo } from 'react';
import { useParams, Link } from 'react-router-dom';
import { getAttendances, getUserById } from '../services/dataService';
import { CURRENT_TENANT_ID } from '../constants';
import { Attendance, User, FunnelStage } from '../types';
import { ArrowLeft, Mail, Phone, Clock, MessageSquare, ChevronLeft, ChevronRight, Eye } from 'lucide-react';
@@ -20,11 +19,12 @@ export const UserDetail: React.FC = () => {
const loadData = async () => {
try {
const tenantId = localStorage.getItem('ctms_tenant_id');
const u = await getUserById(id);
setUser(u);
if (u) {
const data = await getAttendances(CURRENT_TENANT_ID, {
if (u && tenantId) {
const data = await getAttendances(tenantId, {
userId: id,
dateRange: { start: new Date(0), end: new Date() } // All time
});

116
pages/VerifyCode.tsx Normal file
View 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>
);
};