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
118 lines
6.1 KiB
TypeScript
118 lines
6.1 KiB
TypeScript
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>
|
|
);
|
|
};
|