All checks were successful
Build and Deploy / build-and-push (push) Successful in 2m32s
- Updated DB schema to support multiple funnels (funnels table) and their stages (funnel_stages table).
- Added funnel_id to teams table to link teams to specific funnels.
- Redesigned /admin/funnels page ('Meus Funis') to allow creating multiple funnels, managing their stages, and assigning them to teams.
- Updated Dashboard, UserDetail, and AttendanceDetail to dynamically load the correct funnel based on the selected team or user's assigned team.
286 lines
15 KiB
TypeScript
286 lines
15 KiB
TypeScript
import React, { useEffect, useState, useMemo } from 'react';
|
|
import { useParams, Link } from 'react-router-dom';
|
|
import { getAttendances, getUserById, getFunnels } from '../services/dataService';
|
|
import { Attendance, User, FunnelStage, DashboardFilter, FunnelStageDef } from '../types';
|
|
import { ArrowLeft, Mail, Phone, Clock, MessageSquare, ChevronLeft, ChevronRight, Eye, Filter } from 'lucide-react';
|
|
import { DateRangePicker } from '../components/DateRangePicker';
|
|
|
|
const ITEMS_PER_PAGE = 10;
|
|
|
|
export const UserDetail: React.FC = () => {
|
|
const { id } = useParams<{ id: string }>();
|
|
const [user, setUser] = useState<User | undefined>();
|
|
const [attendances, setAttendances] = useState<Attendance[]>([]);
|
|
const [funnelDefs, setFunnelDefs] = useState<FunnelStageDef[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [filters, setFilters] = useState<DashboardFilter>({
|
|
dateRange: { start: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), end: new Date() },
|
|
userId: id,
|
|
funnelStage: 'all',
|
|
origin: 'all'
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (id) {
|
|
setLoading(true);
|
|
|
|
const loadData = async () => {
|
|
try {
|
|
const tenantId = localStorage.getItem('ctms_tenant_id');
|
|
const u = await getUserById(id);
|
|
setUser(u);
|
|
|
|
if (u && tenantId) {
|
|
const [data, fetchedFunnels] = await Promise.all([
|
|
getAttendances(tenantId, {
|
|
...filters,
|
|
userId: id
|
|
}),
|
|
getFunnels(tenantId)
|
|
]);
|
|
setAttendances(data);
|
|
|
|
const targetTeamId = u.team_id || null;
|
|
let activeFunnel = fetchedFunnels[0];
|
|
if (targetTeamId) {
|
|
const matchedFunnel = fetchedFunnels.find(f => f.teamIds?.includes(targetTeamId));
|
|
if (matchedFunnel) activeFunnel = matchedFunnel;
|
|
}
|
|
setFunnelDefs(activeFunnel && activeFunnel.stages ? activeFunnel.stages.sort((a: any, b: any) => a.order_index - b.order_index) : []);
|
|
}
|
|
} catch (error) {
|
|
console.error("Error loading user details", error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
loadData();
|
|
}
|
|
}, [id, filters]);
|
|
|
|
const handleFilterChange = (key: keyof DashboardFilter, value: any) => {
|
|
setFilters(prev => ({ ...prev, [key]: value }));
|
|
setCurrentPage(1); // Reset pagination on filter change
|
|
};
|
|
|
|
const totalPages = Math.ceil(attendances.length / ITEMS_PER_PAGE);
|
|
|
|
const currentData = useMemo(() => {
|
|
const start = (currentPage - 1) * ITEMS_PER_PAGE;
|
|
return attendances.slice(start, start + ITEMS_PER_PAGE);
|
|
}, [attendances, currentPage]);
|
|
|
|
const handlePageChange = (newPage: number) => {
|
|
if (newPage >= 1 && newPage <= totalPages) {
|
|
setCurrentPage(newPage);
|
|
}
|
|
};
|
|
|
|
const getStageBadgeColor = (stage: string) => {
|
|
const def = funnelDefs.find(f => f.name === stage);
|
|
if (def) return def.color_class;
|
|
|
|
switch (stage) {
|
|
case FunnelStage.WON: return 'bg-green-100 text-green-700 border-green-200 dark:bg-green-900/30 dark:text-green-400 dark:border-green-800';
|
|
case FunnelStage.LOST: return 'bg-red-100 text-red-700 border-red-200 dark:bg-red-900/30 dark:text-red-400 dark:border-red-800';
|
|
case FunnelStage.NEGOTIATION: return 'bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:border-blue-800';
|
|
case FunnelStage.IDENTIFICATION: return 'bg-yellow-100 text-yellow-700 border-yellow-200 dark:bg-yellow-900/30 dark:text-yellow-400 dark:border-yellow-800';
|
|
default: return 'bg-zinc-100 text-zinc-700 border-zinc-200 dark:bg-zinc-800 dark:text-zinc-300 dark:border-zinc-700';
|
|
}
|
|
};
|
|
|
|
const getScoreColor = (score: number) => {
|
|
if (score >= 80) return 'text-green-600 bg-green-50 dark:bg-green-900/20 dark:text-green-400';
|
|
if (score >= 60) return 'text-yellow-600 bg-yellow-50 dark:bg-yellow-900/20 dark:text-yellow-400';
|
|
return 'text-red-600 bg-red-50 dark:bg-red-900/20 dark:text-red-400';
|
|
};
|
|
|
|
if (!loading && !user) return <div className="p-8 text-zinc-500 dark:text-dark-muted transition-colors">Usuário não encontrado</div>;
|
|
|
|
const backendUrl = import.meta.env.PROD ? '' : 'http://localhost:3001';
|
|
|
|
return (
|
|
<div className="space-y-8 max-w-7xl mx-auto transition-colors duration-300">
|
|
{/* Header Section */}
|
|
<div className="flex flex-col md:flex-row gap-6 items-start md:items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<Link to="/" className="p-2.5 bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-lg text-zinc-500 dark:text-dark-muted hover:bg-zinc-50 dark:hover:bg-zinc-800 hover:text-zinc-900 dark:hover:text-dark-text transition-all shadow-sm">
|
|
<ArrowLeft size={18} />
|
|
</Link>
|
|
{user && (
|
|
<div className="flex items-center gap-4">
|
|
<img
|
|
src={user.avatar_url
|
|
? (user.avatar_url.startsWith('http') ? user.avatar_url : `${backendUrl}${user.avatar_url}`)
|
|
: `https://ui-avatars.com/api/?name=${encodeURIComponent(user.name)}&background=random`}
|
|
alt={user.name}
|
|
className="w-16 h-16 rounded-full border-2 border-white dark:border-dark-border shadow-md object-cover"
|
|
/>
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-zinc-900 dark:text-dark-text tracking-tight">{user.name}</h1>
|
|
<div className="flex items-center gap-3 text-sm text-zinc-500 dark:text-dark-muted mt-1">
|
|
<span className="flex items-center gap-1.5"><Mail size={14} /> {user.email}</span>
|
|
<span className="bg-zinc-100 dark:bg-dark-bg text-zinc-600 dark:text-dark-muted px-2 py-0.5 rounded text-xs font-semibold uppercase">{user.role}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filters Section */}
|
|
<div className="bg-white dark:bg-dark-card p-4 rounded-xl border border-zinc-200 dark:border-dark-border shadow-sm flex flex-wrap items-center gap-4">
|
|
<div className="flex items-center gap-2 text-zinc-500 dark:text-dark-muted font-medium mr-2">
|
|
<Filter size={18} />
|
|
<span>Filtros:</span>
|
|
</div>
|
|
|
|
<DateRangePicker
|
|
dateRange={filters.dateRange}
|
|
onChange={(range) => handleFilterChange('dateRange', range)}
|
|
/>
|
|
|
|
<select
|
|
className="bg-zinc-50 dark:bg-dark-bg border border-zinc-200 dark:border-dark-border px-3 py-2 rounded-lg text-sm text-zinc-700 dark:text-zinc-200 outline-none focus:ring-2 focus:ring-brand-yellow/20 cursor-pointer hover:border-zinc-300 dark:hover:border-zinc-700 transition-all"
|
|
value={filters.funnelStage}
|
|
onChange={(e) => handleFilterChange('funnelStage', e.target.value)}
|
|
>
|
|
<option value="all">Todas Etapas</option>
|
|
{funnelDefs.length > 0 ? funnelDefs.map(stage => (
|
|
<option key={stage.id} value={stage.name}>{stage.name}</option>
|
|
)) : Object.values(FunnelStage).map(stage => (
|
|
<option key={stage} value={stage}>{stage}</option>
|
|
))}
|
|
</select>
|
|
<select
|
|
className="bg-zinc-50 dark:bg-dark-bg border border-zinc-200 dark:border-dark-border px-3 py-2 rounded-lg text-sm text-zinc-700 dark:text-zinc-200 outline-none focus:ring-2 focus:ring-brand-yellow/20 cursor-pointer hover:border-zinc-300 dark:hover:border-zinc-700 transition-all"
|
|
value={filters.origin}
|
|
onChange={(e) => handleFilterChange('origin', e.target.value)}
|
|
>
|
|
<option value="all">Todas Origens</option>
|
|
<option value="WhatsApp">WhatsApp</option>
|
|
<option value="Instagram">Instagram</option>
|
|
<option value="Website">Website</option>
|
|
<option value="LinkedIn">LinkedIn</option>
|
|
<option value="Indicação">Indicação</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* KPI Cards */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
<div className="bg-white dark:bg-dark-card p-6 rounded-xl border border-zinc-200 dark:border-dark-border shadow-sm transition-colors">
|
|
<div className="text-sm font-medium text-zinc-500 dark:text-dark-muted mb-2">Total de Interações</div>
|
|
<div className="text-3xl font-bold text-zinc-900 dark:text-dark-text">{attendances.length}</div>
|
|
</div>
|
|
<div className="bg-white dark:bg-dark-card p-6 rounded-xl border border-zinc-200 dark:border-dark-border shadow-sm transition-colors">
|
|
<div className="text-sm font-medium text-zinc-500 dark:text-dark-muted mb-2">Taxa de Conversão</div>
|
|
<div className="text-3xl font-bold text-brand-yellow">
|
|
{attendances.length ? ((attendances.filter(a => a.converted).length / attendances.length) * 100).toFixed(1) : 0}%
|
|
</div>
|
|
</div>
|
|
<div className="bg-white dark:bg-dark-card p-6 rounded-xl border border-zinc-200 dark:border-dark-border shadow-sm transition-colors">
|
|
<div className="text-sm font-medium text-zinc-500 dark:text-dark-muted mb-2">Nota Média</div>
|
|
<div className="text-3xl font-bold text-zinc-800 dark:text-zinc-100">
|
|
{attendances.length ? (attendances.reduce((acc, c) => acc + c.score, 0) / attendances.length).toFixed(1) : 0}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Attendance Table */}
|
|
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-xl shadow-sm overflow-hidden flex flex-col transition-colors">
|
|
<div className="px-6 py-4 border-b border-zinc-100 dark:border-zinc-800 flex justify-between items-center bg-zinc-50/50 dark:bg-dark-bg/50">
|
|
<h2 className="font-semibold text-zinc-900 dark:text-zinc-100">Histórico de Atendimentos</h2>
|
|
<span className="text-xs text-zinc-500 dark:text-dark-muted font-medium bg-zinc-200/50 dark:bg-zinc-800 px-2 py-1 rounded transition-colors">Página {currentPage} de {totalPages || 1}</span>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="p-12 text-center text-zinc-400 dark:text-dark-muted">Carregando registros...</div>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left border-collapse">
|
|
<thead>
|
|
<tr className="border-b border-zinc-100 dark:border-zinc-800 text-xs uppercase text-zinc-500 dark:text-dark-muted bg-zinc-50/30 dark:bg-dark-bg/30">
|
|
<th className="px-6 py-4 font-medium">Data / Hora</th>
|
|
<th className="px-6 py-4 font-medium w-1/3">Resumo</th>
|
|
<th className="px-6 py-4 font-medium text-center">Etapa</th>
|
|
<th className="px-6 py-4 font-medium text-center">Nota</th>
|
|
<th className="px-6 py-4 font-medium text-center">T. Resp.</th>
|
|
<th className="px-6 py-4 font-medium text-right">Ação</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800 text-sm">
|
|
{currentData.map(att => (
|
|
<tr key={att.id} className="hover:bg-zinc-50/80 dark:hover:bg-zinc-800/50 transition-colors group">
|
|
<td className="px-6 py-4 text-zinc-600 dark:text-zinc-300 whitespace-nowrap">
|
|
<div className="font-medium text-zinc-900 dark:text-zinc-100">{new Date(att.created_at).toLocaleDateString()}</div>
|
|
<div className="text-xs text-zinc-400 dark:text-dark-muted">{new Date(att.created_at).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}</div>
|
|
</td>
|
|
<td className="px-6 py-4">
|
|
<div className="flex flex-col">
|
|
<span className="text-zinc-800 dark:text-zinc-200 line-clamp-1 font-medium mb-1">{att.summary}</span>
|
|
<div className="flex items-center gap-2 text-xs text-zinc-500 dark:text-dark-muted">
|
|
<span className="flex items-center gap-1"><MessageSquare size={10} /> {att.origin}</span>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 text-center">
|
|
<span className={`inline-flex px-2.5 py-0.5 rounded-full text-xs font-semibold border ${getStageBadgeColor(att.funnel_stage)}`}>
|
|
{att.funnel_stage}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4 text-center">
|
|
<span className={`inline-flex items-center justify-center w-8 h-8 rounded-lg font-bold text-sm ${getScoreColor(att.score)}`}>
|
|
{att.score}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4 text-center text-zinc-600 dark:text-zinc-300">
|
|
<div className="flex items-center justify-center gap-1">
|
|
<Clock size={14} className="text-zinc-400 dark:text-dark-muted" />
|
|
{att.first_response_time_min}m
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 text-right">
|
|
<Link
|
|
to={`/attendances/${att.id}`}
|
|
className="inline-flex items-center justify-center p-2 rounded-lg text-zinc-400 dark:text-dark-muted hover:text-brand-yellow hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-all"
|
|
title="Ver Detalhes"
|
|
>
|
|
<Eye size={18} />
|
|
</Link>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
{/* Pagination Footer */}
|
|
{totalPages > 1 && (
|
|
<div className="px-6 py-4 border-t border-zinc-100 dark:border-zinc-800 flex items-center justify-between bg-zinc-50/30 dark:bg-dark-bg/30">
|
|
<button
|
|
onClick={() => handlePageChange(currentPage - 1)}
|
|
disabled={currentPage === 1}
|
|
className="flex items-center gap-1 px-3 py-2 text-sm font-medium text-zinc-600 dark:text-dark-text bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-sm"
|
|
>
|
|
<ChevronLeft size={16} /> Anterior
|
|
</button>
|
|
<div className="text-sm text-zinc-500 dark:text-dark-muted">
|
|
Mostrando <span className="font-medium text-zinc-900 dark:text-zinc-100">{((currentPage - 1) * ITEMS_PER_PAGE) + 1}</span> a <span className="font-medium text-zinc-900 dark:text-zinc-100">{Math.min(currentPage * ITEMS_PER_PAGE, attendances.length)}</span> de {attendances.length}
|
|
</div>
|
|
<button
|
|
onClick={() => handlePageChange(currentPage + 1)}
|
|
disabled={currentPage === totalPages}
|
|
className="flex items-center gap-1 px-3 py-2 text-sm font-medium text-zinc-600 dark:text-dark-text bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-sm"
|
|
>
|
|
Próximo <ChevronRight size={16} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|