Adds initial backend API endpoints for fetching users and attendances, including basic filtering. Sets up the frontend routing with a layout component and includes placeholder pages for dashboard, users, and login. Refactors the README for local development setup.
212 lines
10 KiB
TypeScript
212 lines
10 KiB
TypeScript
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';
|
|
|
|
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 [loading, setLoading] = useState(true);
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
|
|
useEffect(() => {
|
|
if (id) {
|
|
setLoading(true);
|
|
|
|
const loadData = async () => {
|
|
try {
|
|
const u = await getUserById(id);
|
|
setUser(u);
|
|
|
|
if (u) {
|
|
const data = await getAttendances(CURRENT_TENANT_ID, {
|
|
userId: id,
|
|
dateRange: { start: new Date(0), end: new Date() } // All time
|
|
});
|
|
setAttendances(data);
|
|
}
|
|
} catch (error) {
|
|
console.error("Error loading user details", error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
loadData();
|
|
}
|
|
}, [id]);
|
|
|
|
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: FunnelStage) => {
|
|
switch (stage) {
|
|
case FunnelStage.WON: return 'bg-green-100 text-green-700 border-green-200';
|
|
case FunnelStage.LOST: return 'bg-red-100 text-red-700 border-red-200';
|
|
case FunnelStage.NEGOTIATION: return 'bg-blue-100 text-blue-700 border-blue-200';
|
|
case FunnelStage.IDENTIFICATION: return 'bg-yellow-100 text-yellow-700 border-yellow-200';
|
|
default: return 'bg-slate-100 text-slate-700 border-slate-200';
|
|
}
|
|
};
|
|
|
|
const getScoreColor = (score: number) => {
|
|
if (score >= 80) return 'text-green-600 bg-green-50';
|
|
if (score >= 60) return 'text-yellow-600 bg-yellow-50';
|
|
return 'text-red-600 bg-red-50';
|
|
};
|
|
|
|
if (!loading && !user) return <div className="p-8 text-slate-500">Usuário não encontrado</div>;
|
|
|
|
return (
|
|
<div className="space-y-8 max-w-7xl mx-auto">
|
|
{/* 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 border border-slate-200 rounded-lg text-slate-500 hover:bg-slate-50 hover:text-slate-900 transition-colors shadow-sm">
|
|
<ArrowLeft size={18} />
|
|
</Link>
|
|
{user && (
|
|
<div className="flex items-center gap-4">
|
|
<img src={user.avatar_url} alt={user.name} className="w-16 h-16 rounded-full border-2 border-white shadow-md object-cover" />
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-slate-900 tracking-tight">{user.name}</h1>
|
|
<div className="flex items-center gap-3 text-sm text-slate-500 mt-1">
|
|
<span className="flex items-center gap-1.5"><Mail size={14} /> {user.email}</span>
|
|
<span className="bg-slate-100 text-slate-600 px-2 py-0.5 rounded text-xs font-semibold uppercase">{user.role}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* KPI Cards */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm">
|
|
<div className="text-sm font-medium text-slate-500 mb-2">Total de Interações</div>
|
|
<div className="text-3xl font-bold text-slate-900">{attendances.length}</div>
|
|
</div>
|
|
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm">
|
|
<div className="text-sm font-medium text-slate-500 mb-2">Taxa de Conversão</div>
|
|
<div className="text-3xl font-bold text-blue-600">
|
|
{attendances.length ? ((attendances.filter(a => a.converted).length / attendances.length) * 100).toFixed(1) : 0}%
|
|
</div>
|
|
</div>
|
|
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm">
|
|
<div className="text-sm font-medium text-slate-500 mb-2">Nota Média</div>
|
|
<div className="text-3xl font-bold text-yellow-500">
|
|
{attendances.length ? (attendances.reduce((acc, c) => acc + c.score, 0) / attendances.length).toFixed(1) : 0}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Attendance Table */}
|
|
<div className="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden flex flex-col">
|
|
<div className="px-6 py-4 border-b border-slate-100 flex justify-between items-center bg-slate-50/50">
|
|
<h2 className="font-semibold text-slate-900">Histórico de Atendimentos</h2>
|
|
<span className="text-xs text-slate-500 font-medium bg-slate-200/50 px-2 py-1 rounded">Página {currentPage} de {totalPages || 1}</span>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="p-12 text-center text-slate-400">Carregando registros...</div>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left border-collapse">
|
|
<thead>
|
|
<tr className="border-b border-slate-100 text-xs uppercase text-slate-500 bg-slate-50/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-slate-100 text-sm">
|
|
{currentData.map(att => (
|
|
<tr key={att.id} className="hover:bg-slate-50/80 transition-colors group">
|
|
<td className="px-6 py-4 text-slate-600 whitespace-nowrap">
|
|
<div className="font-medium text-slate-900">{new Date(att.created_at).toLocaleDateString()}</div>
|
|
<div className="text-xs text-slate-400">{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-slate-800 line-clamp-1 font-medium mb-1">{att.summary}</span>
|
|
<div className="flex items-center gap-2 text-xs text-slate-500">
|
|
<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-slate-600">
|
|
<div className="flex items-center justify-center gap-1">
|
|
<Clock size={14} className="text-slate-400" />
|
|
{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-slate-400 hover:text-blue-600 hover:bg-blue-50 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-slate-100 flex items-center justify-between bg-slate-50/30">
|
|
<button
|
|
onClick={() => handlePageChange(currentPage - 1)}
|
|
disabled={currentPage === 1}
|
|
className="flex items-center gap-1 px-3 py-2 text-sm font-medium text-slate-600 bg-white border border-slate-200 rounded-lg hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-sm"
|
|
>
|
|
<ChevronLeft size={16} /> Anterior
|
|
</button>
|
|
<div className="text-sm text-slate-500">
|
|
Mostrando <span className="font-medium text-slate-900">{((currentPage - 1) * ITEMS_PER_PAGE) + 1}</span> a <span className="font-medium text-slate-900">{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-slate-600 bg-white border border-slate-200 rounded-lg hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-sm"
|
|
>
|
|
Próximo <ChevronRight size={16} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}; |