Files
fasto/pages/UserDetail.tsx
Cauê Faleiros 20bdf510fd
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m54s
feat: implement secure multi-tenancy, RBAC, and premium dark mode
- 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
2026-03-03 17:16:55 -03:00

264 lines
14 KiB
TypeScript

import React, { useEffect, useState, useMemo } from 'react';
import { useParams, Link } from 'react-router-dom';
import { getAttendances, getUserById } from '../services/dataService';
import { Attendance, User, FunnelStage, DashboardFilter } from '../types';
import { ArrowLeft, Mail, Phone, Clock, MessageSquare, ChevronLeft, ChevronRight, Eye, Filter } 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);
const [filters, setFilters] = useState<DashboardFilter>({
dateRange: { start: new Date(0), 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 = await getAttendances(tenantId, {
...filters,
userId: id
});
setAttendances(data);
}
} 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: FunnelStage) => {
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>
<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>
{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>
);
};