feat: implement secure multi-tenancy, RBAC, and premium dark mode
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m54s

- Enforced tenant isolation and Role-Based Access Control across all API routes

- Implemented secure profile avatar upload using multer and UUIDs

- Redesigned UI with a premium "Onyx & Gold" Charcoal dark mode

- Added Funnel Stage and Origin filters to Dashboard and User Detail pages

- Replaced "Referral" with "Indicação" across the platform and database

- Optimized Dockerfile and local environment setup for reliable deployments

- Fixed frontend syntax errors and improved KPI/Chart visualizations
This commit is contained in:
Cauê Faleiros
2026-03-03 17:16:55 -03:00
parent b7e73fce3d
commit 20bdf510fd
32 changed files with 2810 additions and 1140 deletions

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useMemo } from 'react';
import { Building2, Users, Plus, Search, Target, ArrowUpRight, Loader2, CheckCircle2, AlertCircle, Edit2, X } from 'lucide-react';
import { Building2, Users, Plus, Search, Target, ArrowUpRight, Loader2, Edit2, X } from 'lucide-react';
import { getTeams, getUsers, getAttendances, createTeam, updateTeam } from '../services/dataService';
import { User, Attendance } from '../types';
@@ -11,15 +11,8 @@ export const Teams: React.FC = () => {
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;
@@ -41,77 +34,84 @@ export const Teams: React.FC = () => {
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) };
return { ...t, memberCount: tu.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); }
const success = editingTeam
? await updateTeam(editingTeam.id, formData)
: await createTeam({ ...formData, tenantId: tid });
if (success) { setIsModalOpen(false); loadData(); }
} catch (err) { alert('Erro ao salvar'); } finally { setIsSaving(false); }
};
if (loading && teams.length === 0) return <div className="p-12 text-center text-slate-400">Carregando...</div>;
if (loading && teams.length === 0) return <div className="p-12 text-center text-zinc-400 dark:text-dark-muted transition-colors">Carregando...</div>;
return (
<div className="space-y-8 max-w-7xl mx-auto pb-12">
<div className="space-y-8 max-w-7xl mx-auto pb-12 transition-colors duration-300">
<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>
<h1 className="text-2xl font-bold text-zinc-900 dark:text-zinc-50 tracking-tight">Times</h1>
<p className="text-zinc-500 dark:text-dark-muted text-sm">Visualize o desempenho e gerencie seus grupos de vendas.</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">
<button onClick={() => { setEditingTeam(null); setFormData({name:'', description:''}); setIsModalOpen(true); }} className="bg-zinc-900 dark:bg-brand-yellow text-white dark:text-zinc-950 px-4 py-2 rounded-lg flex items-center gap-2 text-sm font-bold shadow-sm hover:opacity-90 transition-all">
<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 className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{stats.map(t => (
<div key={t.id} className="bg-white dark:bg-dark-card rounded-2xl border border-zinc-200 dark:border-dark-border p-6 shadow-sm group hover:shadow-md transition-all">
<div className="flex justify-between mb-6">
<div className="p-3 bg-zinc-50 dark:bg-dark-bg text-zinc-600 dark:text-dark-muted rounded-xl border border-zinc-100 dark:border-dark-border"><Building2 size={24} /></div>
<button onClick={() => { setEditingTeam(t); setFormData({name:t.name, description:t.description||''}); setIsModalOpen(true); }} className="text-zinc-400 dark:text-dark-muted hover:text-zinc-900 dark:hover:text-dark-text opacity-0 group-hover:opacity-100 p-2 rounded-lg hover:bg-zinc-50 dark:hover:bg-dark-bg transition-all"><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>
<h3 className="text-lg font-bold text-zinc-900 dark:text-zinc-50 mb-1">{t.name}</h3>
<p className="text-sm text-zinc-500 dark:text-dark-muted mb-6 h-10 line-clamp-2">{t.description || 'Sem descrição definida.'}</p>
<div className="grid grid-cols-2 gap-4 pt-6 border-t border-zinc-50 dark:border-dark-border text-sm">
<div className="space-y-1">
<span className="text-zinc-400 dark:text-dark-muted block text-[10px] font-bold uppercase tracking-wider">Membros</span>
<strong className="text-lg text-zinc-800 dark:text-zinc-200">{t.memberCount}</strong>
</div>
<div className="space-y-1 text-right">
<span className="text-zinc-400 dark:text-dark-muted block text-[10px] font-bold uppercase tracking-wider">Conversão</span>
<strong className="text-lg text-brand-yellow">{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 className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-zinc-950/80 backdrop-blur-sm">
<div className="bg-white dark:bg-dark-card rounded-2xl p-8 w-full max-w-md shadow-2xl animate-in zoom-in duration-200 transition-colors border border-transparent dark:border-dark-border">
<div className="flex justify-between items-center mb-6 pb-4 border-b border-zinc-100 dark:border-dark-border">
<h3 className="text-xl font-bold text-zinc-900 dark:text-zinc-50">{editingTeam ? 'Editar Time' : 'Novo Time'}</h3>
<button onClick={() => setIsModalOpen(false)} className="text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 transition-colors"><X size={20} /></button>
</div>
<form onSubmit={handleSave} className="space-y-5">
<div>
<label className="text-xs font-bold text-zinc-500 dark:text-dark-muted uppercase mb-1.5 block tracking-wider">Nome do Time</label>
<input type="text" placeholder="Ex: Vendas Sul" value={formData.name} onChange={e => setFormData({...formData, name:e.target.value})} className="w-full bg-white dark:bg-dark-input border border-zinc-200 dark:border-dark-border p-3 rounded-lg text-sm text-zinc-900 dark:text-zinc-100 outline-none focus:ring-2 focus:ring-brand-yellow/20 transition-all" required />
</div>
<div>
<label className="text-xs font-bold text-zinc-500 dark:text-dark-muted uppercase mb-1.5 block tracking-wider">Descrição</label>
<textarea placeholder="Breve descrição..." value={formData.description} onChange={e => setFormData({...formData, description:e.target.value})} className="w-full bg-white dark:bg-dark-input border border-zinc-200 dark:border-dark-border p-3 rounded-lg text-sm text-zinc-900 dark:text-zinc-100 resize-none h-28 outline-none focus:ring-2 focus:ring-brand-yellow/20 transition-all" />
</div>
<div className="flex justify-end gap-3 pt-6 border-t dark:border-dark-border mt-6">
<button type="button" onClick={() => setIsModalOpen(false)} className="px-6 py-2.5 text-sm font-medium text-zinc-500 dark:text-dark-muted hover:bg-zinc-100 dark:hover:bg-dark-bg rounded-lg transition-colors">Cancelar</button>
<button type="submit" disabled={isSaving} className="bg-zinc-900 dark:bg-brand-yellow text-white dark:text-zinc-950 px-8 py-2.5 rounded-lg text-sm font-bold shadow-lg transition-all hover:opacity-90">
{isSaving ? <Loader2 className="animate-spin" size={16} /> : 'Salvar Time'}
</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>
);
};