photum/frontend/pages/Team.tsx
NANDO9322 542c8d4388 feat(profissionais): implementa importação via Excel e dashboard de equipe
Frontend:
- Implementa leitura e processamento de arquivos Excel (.xlsx) para Profissionais.
- Adiciona validação e truncamento automático de campos (CPF, UF, Whatsapp) para evitar erros.
- Cria lógica de mapeamento automático de Funções (ex: Fotógrafo, Cinegrafista).
- Adiciona card "Total Geral" na dashboard de Equipe (/equipe).

Backend:
- Cria endpoint e serviço de importação para cadastro em massa.
- Implementa tratamento de erros robusto e prevenção de panics (nil pointers).
- Ajusta queries de inserção e atualização (Upsert) no banco de dados.

Geral:
- Funcionalidade de importação estabilizada e validada.
- Implementa fluxo de edicao inteligente e otimizacoes
- Implementa deteccao de CPF existente no Admin (TeamPage) com redirecionamento automatico para Edicao.
- Isola formulario em ProfessionalModal para performance.
- Adiciona pre-checagem de CPF na API publica (retornando apenas dados seguros).
- Otimiza renderizacao da lista de equipe.
2026-02-02 16:15:16 -03:00

366 lines
15 KiB
TypeScript

import React, { useState, useEffect } from "react";
import {
Users,
Plus,
Search,
Filter,
Trash2,
Edit2,
Star,
Camera,
Video,
UserCheck,
} from "lucide-react";
import { Button } from "../components/Button";
import {
getFunctions,
getProfessionals,
deleteProfessional,
} from "../services/apiService";
import { useAuth } from "../contexts/AuthContext";
import { Professional } from "../types";
import { ProfessionalDetailsModal } from "../components/ProfessionalDetailsModal";
import { ProfessionalModal } from "../components/ProfessionalModal";
export const TeamPage: React.FC = () => {
const { token: contextToken } = useAuth();
const token = contextToken || "";
// Lists
const [professionals, setProfessionals] = useState<Professional[]>([]);
const [roles, setRoles] = useState<{ id: string; nome: string }[]>([]);
// Loading States
const [isLoading, setIsLoading] = useState(true);
// Filters
const [searchTerm, setSearchTerm] = useState("");
const [roleFilter, setRoleFilter] = useState("all");
const [ratingFilter, setRatingFilter] = useState("all");
// Selection & Modals
const [selectedProfessional, setSelectedProfessional] = useState<Professional | null>(null);
const [showModal, setShowModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [professionalToDelete, setProfessionalToDelete] = useState<Professional | null>(null);
const [viewProfessional, setViewProfessional] = useState<Professional | null>(null);
// Helper renderers
const getRoleName = (id: string) => {
return roles.find((r) => r.id === id)?.nome || "Desconhecido";
};
const getRoleIcon = (roleName: string) => {
const lower = roleName.toLowerCase();
if (lower.includes("foto")) return Camera;
if (lower.includes("video") || lower.includes("cine")) return Video;
return UserCheck;
};
const GenericAvatar = "https://ui-avatars.com/api/?background=random";
// Fetch Data
useEffect(() => {
fetchData();
}, [token]);
const fetchData = async () => {
setIsLoading(true);
try {
const [rolesData, prosData] = await Promise.all([
getFunctions(),
getProfessionals(token),
]);
if (rolesData.data) setRoles(rolesData.data);
if (prosData.data) {
setProfessionals(prosData.data);
}
} catch (error) {
console.error("Error fetching data:", error);
} finally {
setIsLoading(false);
}
};
const handleDelete = async () => {
if (!professionalToDelete) return;
try {
await deleteProfessional(professionalToDelete.id, token);
setShowDeleteModal(false);
setProfessionalToDelete(null);
fetchData();
} catch (error) {
console.error("Error deleting professional:", error);
alert("Erro ao excluir profissional.");
}
};
const handleEditClick = (professional: Professional) => {
setSelectedProfessional(professional);
setShowModal(true);
};
const handleViewClick = (professional: Professional) => {
setViewProfessional(professional);
};
// Optimized Filter
const filteredProfessionals = React.useMemo(() => {
return professionals.filter((p) => {
const matchesSearch =
p.nome.toLowerCase().includes(searchTerm.toLowerCase()) ||
(p.email && p.email.toLowerCase().includes(searchTerm.toLowerCase()));
const roleName = getRoleName(p.funcao_profissional_id);
const matchesRole = roleFilter === "all" || roleName === roleFilter;
const matchesRating = (() => {
if (ratingFilter === "all") return true;
const rating = p.media || 0;
switch (ratingFilter) {
case "5": return rating >= 4.5;
case "4": return rating >= 4 && rating < 4.5;
case "3": return rating >= 3 && rating < 4;
case "2": return rating >= 2 && rating < 3;
case "1": return rating >= 1 && rating < 2;
case "0": return rating < 1;
default: return true;
}
})();
if (roleName === "Desconhecido") return false;
return matchesSearch && matchesRole && matchesRating;
});
}, [professionals, searchTerm, roleFilter, ratingFilter, roles]);
return (
<div className="min-h-screen bg-gray-50 pt-20 sm:pt-24 md:pt-28 lg:pt-32 pb-8 sm:pb-12">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Header */}
<div className="mb-6 sm:mb-8">
<h1 className="text-2xl sm:text-3xl font-serif font-bold text-brand-black mb-2">
Equipe
</h1>
<p className="text-sm sm:text-base text-gray-600">
Gerencie sua equipe de profissionais
</p>
</div>
{/* Stats */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 sm:gap-4 md:gap-6 mb-6 sm:mb-8">
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 border-l-4 border-l-brand-gold">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600 mb-1">Total de Profissionais</p>
<p className="text-3xl font-bold text-brand-black">{professionals.length}</p>
</div>
<Users className="text-brand-black opacity-80" size={32} />
</div>
</div>
{roles.map(role => {
const count = professionals.filter(p => p.funcao_profissional_id === role.id).length;
const RoleIcon = getRoleIcon(role.nome);
let iconColorClass = "text-brand-black";
if (role.nome.toLowerCase().includes("foto")) iconColorClass = "text-brand-gold";
else if (role.nome.toLowerCase().includes("video") || role.nome.toLowerCase().includes("cine")) iconColorClass = "text-blue-600";
else if (role.nome.toLowerCase().includes("recep")) iconColorClass = "text-purple-600";
return (
<div key={role.id} className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600 mb-1">Total de {role.nome}s</p>
<p className="text-3xl font-bold text-brand-black">{count}</p>
</div>
<RoleIcon className={iconColorClass} size={32} />
</div>
</div>
);
})}
</div>
{/* Filters and Search */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4 mb-6">
<div className="flex flex-col gap-4">
<div className="flex flex-col md:flex-row gap-4 items-center justify-between">
<div className="relative w-full md:w-96">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
<input
type="text"
placeholder="Buscar por nome ou email..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold"
/>
</div>
<Button onClick={() => {
setSelectedProfessional(null);
setShowModal(true);
}}>
<Plus size={20} className="mr-2" />
Adicionar Profissional
</Button>
</div>
<div className="flex flex-col md:flex-row gap-4 items-center">
<div className="flex items-center gap-2">
<Filter size={16} className="text-gray-400" />
<span className="text-sm font-medium text-gray-700">Filtros:</span>
</div>
<select
value={roleFilter}
onChange={(e) => setRoleFilter(e.target.value)}
className="px-3 py-1 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-brand-gold"
>
<option value="all">Todas as Funções</option>
{roles.map(role => (
<option key={role.id} value={role.nome}>{role.nome}</option>
))}
</select>
<select
value={ratingFilter}
onChange={(e) => setRatingFilter(e.target.value)}
className="px-3 py-1 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-brand-gold"
>
<option value="all">Todas as Avaliações</option>
<option value="5"> 4.5+ Estrelas</option>
<option value="4"> 4.0 - 4.4 Estrelas</option>
<option value="3"> 3.0 - 3.9 Estrelas</option>
<option value="2"> 2.0 - 2.9 Estrelas</option>
<option value="1"> 1.0 - 1.9 Estrelas</option>
<option value="0"> Menos de 1.0</option>
</select>
</div>
</div>
</div>
{/* List */}
{isLoading ? (
<div className="text-center py-12">Carregando...</div>
) : (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<div className="overflow-x-auto">
{filteredProfessionals.length === 0 ? (
<div className="text-center py-8 text-gray-500">Nenhum profissional encontrado.</div>
) : (
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Profissional</th>
<th className="px-6 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Função</th>
<th className="px-6 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Contato</th>
<th className="px-6 py-4 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{filteredProfessionals.map((p) => {
return (
<tr key={p.id} className="hover:bg-gray-50 cursor-pointer" onClick={() => handleViewClick(p)}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<img
className="h-10 w-10 rounded-full object-cover"
src={p.avatar_url || p.avatar || GenericAvatar}
alt={p.nome}
/>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">{p.nome}</div>
<div className="text-sm text-gray-500 flex items-center gap-1">
<Star size={12} className="text-brand-gold fill-current" />
{p.media ? p.media.toFixed(1) : "N/A"}
</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{p.functions && p.functions.length > 0
? p.functions.map(f => f.nome).join(", ")
: getRoleName(p.funcao_profissional_id)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{p.whatsapp}</div>
<div className="text-sm text-gray-500">{p.email}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button onClick={(e) => {
e.stopPropagation();
handleEditClick(p);
}} className="text-indigo-600 hover:text-indigo-900 mr-4">
<Edit2 size={18} />
</button>
<button onClick={(e) => {
e.stopPropagation();
setProfessionalToDelete(p);
setShowDeleteModal(true);
}} className="text-red-600 hover:text-red-900">
<Trash2 size={18} />
</button>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>
)}
</div>
{/* Modals */}
<ProfessionalModal
isOpen={showModal}
onClose={() => {
setShowModal(false);
setSelectedProfessional(null);
}}
professional={selectedProfessional}
existingProfessionals={professionals}
onSwitchToEdit={(prof) => {
setSelectedProfessional(prof);
// Modal automatically updates because 'professional' prop changes
}}
roles={roles}
onSuccess={() => {
fetchData();
}}
/>
{viewProfessional && (
<ProfessionalDetailsModal
professional={viewProfessional}
roles={roles}
onClose={() => setViewProfessional(null)}
/>
)}
{showDeleteModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg max-w-md w-full p-6">
<h2 className="text-xl font-bold mb-4">Confirmar Exclusão</h2>
<p className="text-gray-600 mb-6">
Tem certeza que deseja excluir o profissional {professionalToDelete?.nome}? Esta ação não pode ser desfeita.
</p>
<div className="flex justify-end gap-4">
<Button variant="secondary" onClick={() => setShowDeleteModal(false)}>
Cancelar
</Button>
<Button onClick={handleDelete} className="bg-red-600 hover:bg-red-700">
Excluir
</Button>
</div>
</div>
</div>
)}
</div>
);
};