- Frontend: Refatoração do componente UserApproval para corrigir perda de foco nos inputs (extração de modais). - Backend: Implementação da criação automática do perfil profissional (cadastro_profissionais) ao criar um novo usuário admin. - Backend: Correção para evitar duplicidade de profissionais, utilizando o email para vincular ao perfil existente. - API: Ajuste para retornar dados completos (nome, telefone, empresa) na listagem de usuários do admin.
366 lines
15 KiB
TypeScript
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>
|
|
);
|
|
};
|