- Sistema FOT (Formatura Operations Tracking): * Tela de Gestão FOT (/cursos) com tabela Excel-style * Modal CourseForm com 10 campos (FOT, Empresa, Instituição, etc) * Validação de FOT (5 dígitos numéricos) * Edição de turmas ao clicar na linha * Integração com API backend (empresas, níveis educacionais, universidades) - Dashboard renovado (/painel): * Tabela com 8 colunas (FOT, Data, Curso, Instituição, Ano, Empresa, Tipo, Status) * Filtros avançados: FOT (busca numérica), Data, Tipo de Evento * Removidos filtros de Estado e Cidade * Página de detalhes com tabela vertical (12 informações) * Botão Aprovar redireciona para modal de equipe - Sistema de Aprovação Dupla (/aprovacao): * 2 tabelas separadas por abas (Usuários Normais e Profissionais) * Coluna Universidade renomeada para Empresa * Coluna Função nos profissionais * Workflow de aprovação com atribuição de equipe - Cadastro Profissional (/cadastro-profissional): * Formulário específico para fotógrafos * Dropdown de Função Profissional da API * Tratamento de erro quando backend offline - Modal de Criar Evento: * Tipo de Evento como primeiro campo * Nome do Evento (Opcional) como segundo campo - Componentes novos: * EventTable.tsx - Tabela de eventos com ordenação * EventFiltersBar.tsx - Filtros avançados (3 filtros) * CourseForm.tsx - Formulário FOT completo * ProfessionalForm.tsx - Cadastro profissional - API Service: * Integração com backend Go * Endpoints: /api/empresas, /api/funcoes, /api/niveis-educacionais, /api/universidades, /graduation-years - Documentação: * README.md principal atualizado * frontend/README.md atualizado * Documentação completa de componentes e features
521 lines
22 KiB
TypeScript
521 lines
22 KiB
TypeScript
import React, { useState } from "react";
|
|
import { useData } from "../contexts/DataContext";
|
|
import { UserApprovalStatus } from "../types";
|
|
import {
|
|
CheckCircle,
|
|
XCircle,
|
|
Clock,
|
|
Search,
|
|
Filter,
|
|
Users,
|
|
Briefcase,
|
|
} from "lucide-react";
|
|
import { Button } from "../components/Button";
|
|
|
|
interface UserApprovalProps {
|
|
onNavigate?: (page: string) => void;
|
|
}
|
|
|
|
export const UserApproval: React.FC<UserApprovalProps> = ({ onNavigate }) => {
|
|
const { pendingUsers, approveUser, rejectUser } = useData();
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
const [statusFilter, setStatusFilter] = useState<"ALL" | UserApprovalStatus>(
|
|
"ALL"
|
|
);
|
|
const [activeTab, setActiveTab] = useState<"normal" | "professional">(
|
|
"normal"
|
|
);
|
|
const [isProcessing, setIsProcessing] = useState<string | null>(null);
|
|
|
|
const handleApprove = async (userId: string) => {
|
|
setIsProcessing(userId);
|
|
// Simular processamento
|
|
setTimeout(() => {
|
|
approveUser(userId);
|
|
setIsProcessing(null);
|
|
}, 800);
|
|
};
|
|
|
|
const handleReject = async (userId: string) => {
|
|
setIsProcessing(userId);
|
|
// Simular processamento
|
|
setTimeout(() => {
|
|
rejectUser(userId);
|
|
setIsProcessing(null);
|
|
}, 800);
|
|
};
|
|
|
|
// Separar usuários normais e profissionais (profissionais têm role PHOTOGRAPHER)
|
|
const normalUsers = pendingUsers.filter(
|
|
(user) => user.role !== "PHOTOGRAPHER"
|
|
);
|
|
const professionalUsers = pendingUsers.filter(
|
|
(user) => user.role === "PHOTOGRAPHER"
|
|
);
|
|
|
|
// Filtrar usuários baseado na aba ativa
|
|
const currentUsers = activeTab === "normal" ? normalUsers : professionalUsers;
|
|
|
|
const filteredUsers = currentUsers.filter((user) => {
|
|
const matchesSearch =
|
|
user.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
user.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
(user.registeredInstitution
|
|
?.toLowerCase()
|
|
.includes(searchTerm.toLowerCase()) ??
|
|
false);
|
|
|
|
const matchesStatus =
|
|
statusFilter === "ALL" || user.approvalStatus === statusFilter;
|
|
|
|
return matchesSearch && matchesStatus;
|
|
});
|
|
|
|
const getStatusBadge = (status: UserApprovalStatus) => {
|
|
switch (status) {
|
|
case UserApprovalStatus.PENDING:
|
|
return (
|
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
|
<Clock className="w-3 h-3 mr-1" />
|
|
Pendente
|
|
</span>
|
|
);
|
|
case UserApprovalStatus.APPROVED:
|
|
return (
|
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
|
<CheckCircle className="w-3 h-3 mr-1" />
|
|
Aprovado
|
|
</span>
|
|
);
|
|
case UserApprovalStatus.REJECTED:
|
|
return (
|
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
|
<XCircle className="w-3 h-3 mr-1" />
|
|
Rejeitado
|
|
</span>
|
|
);
|
|
}
|
|
};
|
|
|
|
const pendingCount = currentUsers.filter(
|
|
(u) => u.approvalStatus === UserApprovalStatus.PENDING
|
|
).length;
|
|
const approvedCount = currentUsers.filter(
|
|
(u) => u.approvalStatus === UserApprovalStatus.APPROVED
|
|
).length;
|
|
const rejectedCount = currentUsers.filter(
|
|
(u) => u.approvalStatus === UserApprovalStatus.REJECTED
|
|
).length;
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 pt-20 pb-8">
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
{/* Header */}
|
|
<div className="mb-8">
|
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
|
Aprovação de Cadastros
|
|
</h1>
|
|
<p className="text-gray-600">
|
|
Gerencie os cadastros pendentes de aprovação
|
|
</p>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="mb-6 border-b border-gray-200">
|
|
<nav className="-mb-px flex space-x-8">
|
|
<button
|
|
onClick={() => setActiveTab("normal")}
|
|
className={`py-4 px-1 border-b-2 font-medium text-sm flex items-center gap-2 transition-colors ${
|
|
activeTab === "normal"
|
|
? "border-[#B9CF33] text-[#B9CF33]"
|
|
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
|
}`}
|
|
>
|
|
<Users className="w-5 h-5" />
|
|
Cadastros Normais
|
|
<span
|
|
className={`ml-2 py-0.5 px-2.5 rounded-full text-xs ${
|
|
activeTab === "normal"
|
|
? "bg-[#B9CF33] text-white"
|
|
: "bg-gray-200 text-gray-600"
|
|
}`}
|
|
>
|
|
{normalUsers.length}
|
|
</span>
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab("professional")}
|
|
className={`py-4 px-1 border-b-2 font-medium text-sm flex items-center gap-2 transition-colors ${
|
|
activeTab === "professional"
|
|
? "border-[#B9CF33] text-[#B9CF33]"
|
|
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
|
}`}
|
|
>
|
|
<Briefcase className="w-5 h-5" />
|
|
Cadastros Profissionais
|
|
<span
|
|
className={`ml-2 py-0.5 px-2.5 rounded-full text-xs ${
|
|
activeTab === "professional"
|
|
? "bg-[#B9CF33] text-white"
|
|
: "bg-gray-200 text-gray-600"
|
|
}`}
|
|
>
|
|
{professionalUsers.length}
|
|
</span>
|
|
</button>
|
|
</nav>
|
|
</div>
|
|
|
|
{/* Stats Cards */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
|
<div 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 font-medium text-gray-600">Pendentes</p>
|
|
<p className="text-3xl font-bold text-yellow-600">
|
|
{pendingCount}
|
|
</p>
|
|
</div>
|
|
<div className="p-3 bg-yellow-100 rounded-full">
|
|
<Clock className="w-8 h-8 text-yellow-600" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div 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 font-medium text-gray-600">Aprovados</p>
|
|
<p className="text-3xl font-bold text-green-600">
|
|
{approvedCount}
|
|
</p>
|
|
</div>
|
|
<div className="p-3 bg-green-100 rounded-full">
|
|
<CheckCircle className="w-8 h-8 text-green-600" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div 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 font-medium text-gray-600">Rejeitados</p>
|
|
<p className="text-3xl font-bold text-red-600">
|
|
{rejectedCount}
|
|
</p>
|
|
</div>
|
|
<div className="p-3 bg-red-100 rounded-full">
|
|
<XCircle className="w-8 h-8 text-red-600" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4 mb-6">
|
|
<div className="flex flex-col sm:flex-row gap-4">
|
|
{/* Search */}
|
|
<div className="flex-1 relative">
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
|
<input
|
|
type="text"
|
|
placeholder="Buscar por nome, email ou universidade..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-gold focus:border-transparent"
|
|
/>
|
|
</div>
|
|
|
|
{/* Status Filter */}
|
|
<div className="flex items-center gap-2">
|
|
<Filter className="w-5 h-5 text-gray-400" />
|
|
<select
|
|
value={statusFilter}
|
|
onChange={(e) => setStatusFilter(e.target.value as any)}
|
|
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-gold focus:border-transparent"
|
|
>
|
|
<option value="ALL">Todos os Status</option>
|
|
<option value={UserApprovalStatus.PENDING}>Pendentes</option>
|
|
<option value={UserApprovalStatus.APPROVED}>Aprovados</option>
|
|
<option value={UserApprovalStatus.REJECTED}>Rejeitados</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Table */}
|
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
{activeTab === "normal" ? (
|
|
// Tabela de Cadastros Normais
|
|
<table className="min-w-full divide-y divide-gray-200">
|
|
<thead className="bg-gray-50">
|
|
<tr>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Nome
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Email
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Telefone
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Empresa
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Data de Cadastro
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Status
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Ações
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-white divide-y divide-gray-200">
|
|
{filteredUsers.length === 0 ? (
|
|
<tr>
|
|
<td
|
|
colSpan={7}
|
|
className="px-6 py-12 text-center text-gray-500"
|
|
>
|
|
<div className="flex flex-col items-center justify-center">
|
|
<Clock className="w-12 h-12 text-gray-300 mb-3" />
|
|
<p className="text-lg font-medium">
|
|
Nenhum cadastro encontrado
|
|
</p>
|
|
<p className="text-sm">
|
|
Não há cadastros que correspondam aos filtros
|
|
selecionados.
|
|
</p>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
filteredUsers.map((user) => (
|
|
<tr
|
|
key={user.id}
|
|
className="hover:bg-gray-50 transition-colors"
|
|
>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="text-sm font-medium text-gray-900">
|
|
{user.name}
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="text-sm text-gray-600">
|
|
{user.email}
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="text-sm text-gray-600">
|
|
{user.phone || "-"}
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="text-sm text-gray-600">
|
|
{user.registeredInstitution || (
|
|
<span className="text-gray-400 italic">
|
|
Não cadastrado
|
|
</span>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="text-sm text-gray-600">
|
|
{user.createdAt
|
|
? new Date(user.createdAt).toLocaleDateString(
|
|
"pt-BR"
|
|
)
|
|
: "-"}
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
{getStatusBadge(user.approvalStatus!)}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
|
{user.approvalStatus ===
|
|
UserApprovalStatus.PENDING && (
|
|
<div className="flex gap-2">
|
|
<Button
|
|
size="sm"
|
|
onClick={() => handleApprove(user.id)}
|
|
isLoading={isProcessing === user.id}
|
|
disabled={isProcessing !== null}
|
|
className="bg-green-600 hover:bg-green-700 text-white"
|
|
>
|
|
<CheckCircle className="w-4 h-4 mr-1" />
|
|
Aprovar
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={() => handleReject(user.id)}
|
|
isLoading={isProcessing === user.id}
|
|
disabled={isProcessing !== null}
|
|
className="bg-red-600 hover:bg-red-700 text-white"
|
|
>
|
|
<XCircle className="w-4 h-4 mr-1" />
|
|
Rejeitar
|
|
</Button>
|
|
</div>
|
|
)}
|
|
{user.approvalStatus ===
|
|
UserApprovalStatus.APPROVED && (
|
|
<span className="text-green-600 text-xs">
|
|
Aprovado
|
|
</span>
|
|
)}
|
|
{user.approvalStatus ===
|
|
UserApprovalStatus.REJECTED && (
|
|
<span className="text-red-600 text-xs">
|
|
Rejeitado
|
|
</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
) : (
|
|
// Tabela de Cadastros Profissionais
|
|
<table className="min-w-full divide-y divide-gray-200">
|
|
<thead className="bg-gray-50">
|
|
<tr>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Nome
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Email
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Telefone
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Função Profissional
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Data de Cadastro
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Status
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Ações
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-white divide-y divide-gray-200">
|
|
{filteredUsers.length === 0 ? (
|
|
<tr>
|
|
<td
|
|
colSpan={7}
|
|
className="px-6 py-12 text-center text-gray-500"
|
|
>
|
|
<div className="flex flex-col items-center justify-center">
|
|
<Briefcase className="w-12 h-12 text-gray-300 mb-3" />
|
|
<p className="text-lg font-medium">
|
|
Nenhum cadastro profissional encontrado
|
|
</p>
|
|
<p className="text-sm">
|
|
Não há cadastros profissionais que correspondam aos
|
|
filtros selecionados.
|
|
</p>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
filteredUsers.map((user) => (
|
|
<tr
|
|
key={user.id}
|
|
className="hover:bg-gray-50 transition-colors"
|
|
>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="text-sm font-medium text-gray-900">
|
|
{user.name}
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="text-sm text-gray-600">
|
|
{user.email}
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="text-sm text-gray-600">
|
|
{user.phone || "-"}
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="text-sm text-gray-600">
|
|
{(user as any).funcao || (
|
|
<span className="text-gray-400 italic">
|
|
Não cadastrado
|
|
</span>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="text-sm text-gray-600">
|
|
{user.createdAt
|
|
? new Date(user.createdAt).toLocaleDateString(
|
|
"pt-BR"
|
|
)
|
|
: "-"}
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
{getStatusBadge(user.approvalStatus!)}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
|
{user.approvalStatus ===
|
|
UserApprovalStatus.PENDING && (
|
|
<div className="flex gap-2">
|
|
<Button
|
|
size="sm"
|
|
onClick={() => handleApprove(user.id)}
|
|
isLoading={isProcessing === user.id}
|
|
disabled={isProcessing !== null}
|
|
className="bg-green-600 hover:bg-green-700 text-white"
|
|
>
|
|
<CheckCircle className="w-4 h-4 mr-1" />
|
|
Aprovar
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={() => handleReject(user.id)}
|
|
isLoading={isProcessing === user.id}
|
|
disabled={isProcessing !== null}
|
|
className="bg-red-600 hover:bg-red-700 text-white"
|
|
>
|
|
<XCircle className="w-4 h-4 mr-1" />
|
|
Rejeitar
|
|
</Button>
|
|
</div>
|
|
)}
|
|
{user.approvalStatus ===
|
|
UserApprovalStatus.APPROVED && (
|
|
<span className="text-green-600 text-xs">
|
|
Aprovado
|
|
</span>
|
|
)}
|
|
{user.approvalStatus ===
|
|
UserApprovalStatus.REJECTED && (
|
|
<span className="text-red-600 text-xs">
|
|
Rejeitado
|
|
</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|