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.
250 lines
13 KiB
TypeScript
250 lines
13 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
|
import { useParams, Link } from 'react-router-dom';
|
|
import { getAttendanceById, getUserById, getFunnels } from '../services/dataService';
|
|
import { Attendance, User, FunnelStage, FunnelStageDef } from '../types';
|
|
import { ArrowLeft, CheckCircle2, AlertCircle, Clock, Calendar, MessageSquare, ShoppingBag, Award, TrendingUp } from 'lucide-react';
|
|
|
|
export const AttendanceDetail: React.FC = () => {
|
|
const { id } = useParams<{ id: string }>();
|
|
const [data, setData] = useState<Attendance | undefined>();
|
|
const [agent, setAgent] = useState<User | undefined>();
|
|
const [funnelDefs, setFunnelDefs] = useState<FunnelStageDef[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
const loadData = async () => {
|
|
if (id) {
|
|
setLoading(true);
|
|
try {
|
|
const tenantId = localStorage.getItem('ctms_tenant_id') || '';
|
|
const [att, fetchedFunnels] = await Promise.all([
|
|
getAttendanceById(id),
|
|
getFunnels(tenantId)
|
|
]);
|
|
|
|
setData(att);
|
|
|
|
if (att) {
|
|
const u = await getUserById(att.user_id);
|
|
setAgent(u);
|
|
|
|
// Determine which funnel was used based on the agent's team
|
|
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 : []);
|
|
}
|
|
} catch (error) {
|
|
console.error("Error loading details", error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
};
|
|
loadData();
|
|
}, [id]);
|
|
|
|
if (loading) return <div className="p-12 text-center text-zinc-400 dark:text-dark-muted transition-colors">Carregando detalhes...</div>;
|
|
if (!data) return <div className="p-12 text-center text-zinc-500 dark:text-dark-muted transition-colors">Registro de atendimento não encontrado</div>;
|
|
|
|
const getStageColor = (stage: string) => {
|
|
const def = funnelDefs.find(f => f.name === stage);
|
|
if (def) return def.color_class;
|
|
|
|
switch (stage) {
|
|
case FunnelStage.WON: return 'text-green-700 bg-green-50 border-green-200 dark:text-green-400 dark:bg-green-900/30 dark:border-green-800';
|
|
case FunnelStage.LOST: return 'text-red-700 bg-red-50 border-red-200 dark:text-red-400 dark:bg-red-900/30 dark:border-red-800';
|
|
default: return 'text-blue-700 bg-blue-50 border-blue-200 dark:text-blue-400 dark:bg-blue-900/30 dark:border-blue-800';
|
|
}
|
|
};
|
|
|
|
const getScoreColor = (score: number) => {
|
|
if (score >= 80) return 'text-green-500';
|
|
if (score >= 60) return 'text-yellow-500';
|
|
return 'text-red-500';
|
|
};
|
|
|
|
const backendUrl = import.meta.env.PROD ? '' : 'http://localhost:3001';
|
|
|
|
return (
|
|
<div className="max-w-5xl mx-auto space-y-6 transition-colors duration-300">
|
|
{/* Top Nav & Context */}
|
|
<div className="flex items-center justify-between">
|
|
<Link
|
|
to={`/users/${data.user_id}`}
|
|
className="flex items-center gap-2 text-zinc-500 dark:text-dark-muted hover:text-zinc-900 dark:hover:text-dark-text transition-colors text-sm font-medium"
|
|
>
|
|
<ArrowLeft size={16} /> Voltar para Histórico
|
|
</Link>
|
|
<div className="text-sm text-zinc-400 dark:text-dark-muted font-mono">ID: {data.id}</div>
|
|
</div>
|
|
|
|
{/* Hero Header */}
|
|
<div className="bg-white dark:bg-dark-card rounded-2xl border border-zinc-200 dark:border-dark-border shadow-sm p-6 md:p-8 transition-colors">
|
|
<div className="flex flex-col md:flex-row justify-between gap-6">
|
|
<div className="space-y-4">
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<span className={`px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wide border ${getStageColor(data.funnel_stage)}`}>
|
|
{data.funnel_stage}
|
|
</span>
|
|
<span className="flex items-center gap-1.5 text-zinc-500 dark:text-dark-muted text-sm">
|
|
<Calendar size={14} /> {new Date(data.created_at).toLocaleString('pt-BR')}
|
|
</span>
|
|
<span className="flex items-center gap-1.5 text-zinc-500 dark:text-dark-muted text-sm">
|
|
<MessageSquare size={14} /> {data.origin}
|
|
</span>
|
|
</div>
|
|
<h1 className="text-2xl md:text-3xl font-bold text-zinc-900 dark:text-dark-text leading-tight">
|
|
{data.summary}
|
|
</h1>
|
|
{agent && (
|
|
<div className="flex items-center gap-3 pt-2">
|
|
<img
|
|
src={agent.avatar_url
|
|
? (agent.avatar_url.startsWith('http') ? agent.avatar_url : `${backendUrl}${agent.avatar_url}`)
|
|
: `https://ui-avatars.com/api/?name=${encodeURIComponent(agent.name)}&background=random`}
|
|
alt=""
|
|
className="w-8 h-8 rounded-full border border-zinc-200 dark:border-dark-border object-cover"
|
|
/>
|
|
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Agente: <span className="text-zinc-900 dark:text-zinc-100">{agent.name}</span></span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-col items-center justify-center min-w-[140px] p-6 bg-zinc-50 dark:bg-dark-bg/50 rounded-xl border border-zinc-100 dark:border-dark-border">
|
|
<span className="text-xs font-bold text-zinc-400 dark:text-dark-muted uppercase tracking-wider mb-1">Nota de Qualidade</span>
|
|
<div className={`text-5xl font-black ${getScoreColor(data.score)}`}>
|
|
{data.score}
|
|
</div>
|
|
<span className="text-xs font-medium text-zinc-400 dark:text-dark-muted mt-1">de 100</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main Content Grid */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
|
|
{/* Left Column: Analysis */}
|
|
<div className="lg:col-span-2 space-y-6">
|
|
|
|
{/* Summary / Transcript Stub */}
|
|
<div className="bg-white dark:bg-dark-card rounded-xl border border-zinc-200 dark:border-dark-border shadow-sm p-6">
|
|
<h3 className="text-base font-bold text-zinc-900 dark:text-dark-text mb-4 flex items-center gap-2">
|
|
<MessageSquare size={18} className="text-zinc-400 dark:text-dark-muted" />
|
|
Resumo da Interação
|
|
</h3>
|
|
<p className="text-zinc-600 dark:text-zinc-300 leading-relaxed text-sm">
|
|
{data.summary} O cliente perguntou sobre detalhes específicos relacionados ao <span className="font-medium text-zinc-800 dark:text-zinc-100">{data.product_requested}</span>.
|
|
As discussões envolveram níveis de preços, prazos de implementação e potenciais descontos por volume.
|
|
A interação foi concluída com {data.converted ? 'uma venda realizada' : 'o cliente pedindo mais tempo para decidir'}.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Feedback Section */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
{/* Points of Attention */}
|
|
<div className="bg-white dark:bg-dark-card rounded-xl border border-red-100 dark:border-red-900/20 shadow-sm overflow-hidden">
|
|
<div className="px-5 py-3 bg-red-50/50 dark:bg-red-900/20 border-b border-red-100 dark:border-red-900/30 flex items-center gap-2">
|
|
<AlertCircle size={18} className="text-red-500 dark:text-red-400" />
|
|
<h3 className="font-bold text-red-900 dark:text-red-300 text-sm">Pontos de Atenção</h3>
|
|
</div>
|
|
<div className="p-5">
|
|
{data.attention_points && data.attention_points.length > 0 ? (
|
|
<ul className="space-y-3">
|
|
{data.attention_points.map((pt, idx) => (
|
|
<li key={idx} className="flex gap-3 text-sm text-zinc-700 dark:text-zinc-300">
|
|
<span className="mt-1.5 w-1.5 h-1.5 rounded-full bg-red-400 shrink-0" />
|
|
{pt}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : (
|
|
<p className="text-sm text-zinc-400 dark:text-dark-muted italic">Nenhum problema detectado.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Points of Improvement */}
|
|
<div className="bg-white dark:bg-dark-card rounded-xl border border-blue-100 dark:border-blue-900/20 shadow-sm overflow-hidden">
|
|
<div className="px-5 py-3 bg-blue-50/50 dark:bg-blue-900/20 border-b border-blue-100 dark:border-blue-900/30 flex items-center gap-2">
|
|
<CheckCircle2 size={18} className="text-blue-500 dark:text-blue-400" />
|
|
<h3 className="font-bold text-blue-900 dark:text-blue-300 text-sm">Dicas de Melhoria</h3>
|
|
</div>
|
|
<div className="p-5">
|
|
{data.improvement_points && data.improvement_points.length > 0 ? (
|
|
<ul className="space-y-3">
|
|
{data.improvement_points.map((pt, idx) => (
|
|
<li key={idx} className="flex gap-3 text-sm text-zinc-700 dark:text-zinc-300">
|
|
<span className="mt-1.5 w-1.5 h-1.5 rounded-full bg-red-400 shrink-0" />
|
|
{pt}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : (
|
|
<p className="text-sm text-zinc-400 dark:text-dark-muted italic">Continue o bom trabalho!</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right Column: Metadata & Metrics */}
|
|
<div className="space-y-6">
|
|
<div className="bg-white dark:bg-dark-card rounded-xl border border-zinc-200 dark:border-dark-border shadow-sm p-5 transition-colors">
|
|
<h3 className="text-xs font-bold text-zinc-400 dark:text-dark-muted uppercase tracking-wider mb-4">Métricas de Performance</h3>
|
|
<div className="space-y-4">
|
|
<div className="flex justify-between items-center p-3 bg-zinc-50 dark:bg-dark-bg/50 rounded-lg">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 bg-white dark:bg-dark-card rounded-md border border-zinc-100 dark:border-dark-border text-blue-500 dark:text-blue-400"><Clock size={16} /></div>
|
|
<span className="text-sm font-medium text-zinc-600 dark:text-zinc-400">Primeira Resposta</span>
|
|
</div>
|
|
<span className="text-sm font-bold text-zinc-900 dark:text-zinc-100">{data.first_response_time_min} min</span>
|
|
</div>
|
|
|
|
<div className="flex justify-between items-center p-3 bg-zinc-50 dark:bg-dark-bg/50 rounded-lg">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 bg-white dark:bg-dark-card rounded-md border border-zinc-100 dark:border-dark-border text-purple-500 dark:text-purple-400"><TrendingUp size={16} /></div>
|
|
<span className="text-sm font-medium text-zinc-600 dark:text-zinc-400">Tempo Atendimento</span>
|
|
</div>
|
|
<span className="text-sm font-bold text-zinc-900 dark:text-zinc-100">{data.handling_time_min} min</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white dark:bg-dark-card rounded-xl border border-zinc-200 dark:border-dark-border shadow-sm p-5 transition-colors">
|
|
<h3 className="text-xs font-bold text-zinc-400 dark:text-dark-muted uppercase tracking-wider mb-4">Contexto de Vendas</h3>
|
|
<div className="space-y-4">
|
|
<div className="space-y-1">
|
|
<span className="text-xs text-zinc-500 dark:text-dark-muted font-medium">Produto Solicitado</span>
|
|
<div className="flex items-center gap-2 font-semibold text-zinc-800 dark:text-zinc-100">
|
|
<ShoppingBag size={16} className="text-zinc-400 dark:text-dark-muted" /> {data.product_requested}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="h-px bg-zinc-100 dark:bg-dark-border my-2" />
|
|
|
|
<div className="space-y-1">
|
|
<span className="text-xs text-zinc-500 dark:text-dark-muted font-medium">Desfecho</span>
|
|
{data.converted ? (
|
|
<div className="flex items-center gap-2 text-green-600 dark:text-green-400 font-bold bg-green-50 dark:bg-green-900/20 px-3 py-2 rounded-lg border border-green-100 dark:border-green-900/30">
|
|
<Award size={18} /> Venda Fechada
|
|
{data.product_sold && <span className="text-green-800 dark:text-green-200 text-xs font-normal ml-auto">{data.product_sold}</span>}
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-2 text-zinc-500 dark:text-dark-muted font-medium bg-zinc-50 dark:bg-dark-bg/50 px-3 py-2 rounded-lg border border-zinc-100 dark:border-dark-border">
|
|
<div className="w-2 h-2 rounded-full bg-zinc-400 dark:bg-zinc-600" /> Não Convertido
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|