- Adiciona filtro de role RESEARCHER na tela de Aprovação.

- Implementa edição de Role na tela de Aprovação com suporte a funções virtuais (Cine/Recep).
- Atualiza apiService com updateUserRole.
- Corrige visibilidade do Dashboard para RESEARCHER (DataContext).
- Backend: ListPending retorna tipo_profissional original.
This commit is contained in:
NANDO9322 2026-01-31 14:20:51 -03:00
parent b497ea8c72
commit d471b4fc0d
16 changed files with 216 additions and 37 deletions

View file

@ -7,6 +7,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
type Handler struct {
@ -35,6 +36,12 @@ func (h *Handler) Create(c *gin.Context) {
return
}
// Security: Block RESEARCHER
if c.GetString("role") == "RESEARCHER" {
c.JSON(http.StatusForbidden, gin.H{"error": "Acesso negado: Somente leitura"})
return
}
userIDStr := c.GetString("userID")
userID, err := uuid.Parse(userIDStr)
if err != nil {
@ -128,6 +135,12 @@ func (h *Handler) Update(c *gin.Context) {
return
}
// Security: Block RESEARCHER
if c.GetString("role") == "RESEARCHER" {
c.JSON(http.StatusForbidden, gin.H{"error": "Acesso negado: Somente leitura"})
return
}
fmt.Printf("Update Payload: %+v\n", req)
agenda, err := h.service.Update(c.Request.Context(), id, req)
@ -188,6 +201,17 @@ func (h *Handler) AssignProfessional(c *gin.Context) {
return
}
if idParam == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID inválido"})
return
}
// Security: Block RESEARCHER
if c.GetString("role") == "RESEARCHER" {
c.JSON(http.StatusForbidden, gin.H{"error": "Acesso negado: Somente leitura"})
return
}
profID, err := uuid.Parse(req.ProfessionalID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de profissional inválido"})
@ -228,6 +252,12 @@ func (h *Handler) RemoveProfessional(c *gin.Context) {
return
}
// Security: Block RESEARCHER
if c.GetString("role") == "RESEARCHER" {
c.JSON(http.StatusForbidden, gin.H{"error": "Acesso negado: Somente leitura"})
return
}
if err := h.service.RemoveProfessional(c.Request.Context(), agendaID, profID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erro ao remover profissional: " + err.Error()})
return
@ -240,6 +270,7 @@ func (h *Handler) RemoveProfessional(c *gin.Context) {
// @Summary Get professionals assigned to agenda
// @Tags agenda
// @Router /api/agenda/{id}/professionals [get]
func (h *Handler) GetProfessionals(c *gin.Context) {
idParam := c.Param("id")
agendaID, err := uuid.Parse(idParam)
@ -254,6 +285,34 @@ func (h *Handler) GetProfessionals(c *gin.Context) {
return
}
// Security: Mask data for RESEARCHER
if c.GetString("role") == "RESEARCHER" {
for i := range profs {
// Set sensitive fields to generic or empty values
// Using generated struct pointers or values?
// generated.GetAgendaProfessionalsRow has fields like Email (pgtype.Text)
// We can override them.
// Note: 'profs' is a slice of structs, we iterate by index to modify.
// Mask Email
profs[i].Email = pgtype.Text{String: "bloqueado@acesso.restrito", Valid: true}
// Mask Phone/Whatsapp if available in struct
// Checking struct definition... list includes p.*
// p (CadastroProfissionais) has Whatsapp, CpfCnpjTitular, Banco, Agencia, ContaPix
profs[i].Whatsapp = pgtype.Text{String: "Bloqueado", Valid: true}
profs[i].CpfCnpjTitular = pgtype.Text{String: "***", Valid: true}
profs[i].Banco = pgtype.Text{String: "***", Valid: true}
profs[i].Agencia = pgtype.Text{String: "***", Valid: true}
profs[i].ContaPix = pgtype.Text{String: "***", Valid: true}
}
}
c.JSON(http.StatusOK, profs)
}
@ -444,6 +503,12 @@ func (h *Handler) NotifyLogistics(c *gin.Context) {
}
_ = c.ShouldBindJSON(&req)
// Security: Block RESEARCHER
if c.GetString("role") == "RESEARCHER" {
c.JSON(http.StatusForbidden, gin.H{"error": "Acesso negado: Somente leitura"})
return
}
go h.service.NotifyLogistics(context.Background(), agendaID, req.PassengerOrders)
c.JSON(http.StatusOK, gin.H{"message": "Notificação de logística iniciada com sucesso."})

View file

@ -419,14 +419,15 @@ func (h *Handler) ListPending(c *gin.Context) {
}
resp[i] = map[string]interface{}{
"id": uuid.UUID(u.ID.Bytes).String(),
"email": u.Email,
"role": u.Role,
"ativo": u.Ativo,
"created_at": u.CriadoEm.Time,
"name": nome, // Mapped to name for frontend compatibility
"phone": whatsapp,
"company_name": empresaNome,
"id": uuid.UUID(u.ID.Bytes).String(),
"email": u.Email,
"role": u.Role,
"ativo": u.Ativo,
"created_at": u.CriadoEm.Time,
"name": nome, // Mapped to name for frontend compatibility
"phone": whatsapp,
"company_name": empresaNome,
"professional_type": u.TipoProfissional.String, // Add this
}
}

View file

@ -22,6 +22,7 @@ const (
RolePhotographer = "PHOTOGRAPHER"
RoleEventOwner = "EVENT_OWNER"
RoleAgendaViewer = "AGENDA_VIEWER"
RoleResearcher = "RESEARCHER"
)
type Service struct {
@ -269,6 +270,7 @@ func (s *Service) EnsureDemoUsers(ctx context.Context) error {
{"empresa@photum.com", RoleBusinessOwner, "PHOTUM CEO"},
{"foto@photum.com", RolePhotographer, "COLABORADOR PHOTUM"},
{"cliente@photum.com", RoleEventOwner, "CLIENTE TESTE"},
{"pesquisa@photum.com", RoleResearcher, "PESQUISADOR"},
}
for _, u := range demoUsers {

View file

@ -23,7 +23,8 @@ INSERT INTO funcoes_profissionais (nome) VALUES
('Cinegrafista'),
('Recepcionista'),
('Fixo Photum'),
('Controle')
('Controle'),
('Pesquisa')
ON CONFLICT (nome) DO NOTHING;
CREATE TABLE IF NOT EXISTS cadastro_profissionais (

View file

@ -31,6 +31,12 @@ func (h *Handler) Create(c *gin.Context) {
return
}
// Security: Block RESEARCHER
if c.GetString("role") == "RESEARCHER" {
c.JSON(http.StatusForbidden, gin.H{"error": "Acesso negado: Somente leitura"})
return
}
escala, err := h.service.Create(c.Request.Context(), req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@ -76,7 +82,11 @@ func (h *Handler) ListByAgenda(c *gin.Context) {
}
var phone string
if e.Whatsapp.Valid {
phone = e.Whatsapp.String
if c.GetString("role") == "RESEARCHER" {
phone = "Bloqueado"
} else {
phone = e.Whatsapp.String
}
}
var profFuncao string
if e.FuncaoNome.Valid {
@ -116,6 +126,12 @@ func (h *Handler) Delete(c *gin.Context) {
return
}
// Security: Block RESEARCHER
if c.GetString("role") == "RESEARCHER" {
c.JSON(http.StatusForbidden, gin.H{"error": "Acesso negado: Somente leitura"})
return
}
err := h.service.Delete(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@ -148,6 +164,12 @@ func (h *Handler) Update(c *gin.Context) {
return
}
// Security: Block RESEARCHER
if c.GetString("role") == "RESEARCHER" {
c.JSON(http.StatusForbidden, gin.H{"error": "Acesso negado: Somente leitura"})
return
}
_, err := h.service.Update(c.Request.Context(), id, req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})

View file

@ -82,6 +82,10 @@ export const Navbar: React.FC<NavbarProps> = ({ onNavigate, currentPage }) => {
return [
{ name: "Agenda", path: "painel" },
];
case UserRole.RESEARCHER:
return [
{ name: "Painel", path: "painel" },
];
case UserRole.EVENT_OWNER:
return [
{ name: "Meus Eventos", path: "painel" },
@ -104,6 +108,7 @@ export const Navbar: React.FC<NavbarProps> = ({ onNavigate, currentPage }) => {
if (user.role === UserRole.PHOTOGRAPHER) return "Fotógrafo";
if (user.role === UserRole.SUPERADMIN) return "Super Admin";
if (user.role === UserRole.AGENDA_VIEWER) return "Visualizador";
if (user.role === UserRole.RESEARCHER) return "Pesquisa";
return "";
};

View file

@ -1,16 +1,17 @@
import React, { useState } from "react";
import { SimpleCrud } from "./SimpleCrud";
import { PriceTableEditor } from "./PriceTableEditor";
import { Building2, GraduationCap, Calendar, DollarSign, Database } from "lucide-react";
import { Building2, GraduationCap, Calendar, DollarSign, Database, Briefcase } from "lucide-react";
export const SystemSettings: React.FC = () => {
const [activeTab, setActiveTab] = useState<"empresas" | "cursos" | "tipos_evento" | "anos_formatura" | "precos">("empresas");
const [activeTab, setActiveTab] = useState<"empresas" | "cursos" | "tipos_evento" | "anos_formatura" | "precos" | "funcoes">("empresas");
const tabs = [
{ id: "empresas", label: "Empresas", icon: Building2 },
{ id: "cursos", label: "Cursos", icon: GraduationCap },
{ id: "tipos_evento", label: "Tipos de Evento", icon: Calendar },
{ id: "anos_formatura", label: "Anos de Formatura", icon: Database },
{ id: "funcoes", label: "Funções", icon: Briefcase },
{ id: "precos", label: "Tabela de Preços", icon: DollarSign },
];
@ -68,6 +69,13 @@ export const SystemSettings: React.FC = () => {
columns={[{ key: "ano_semestre", label: "Ano/Semestre (Ex: 2024.1)" }]}
/>
)}
{activeTab === "funcoes" && (
<SimpleCrud
title="Gerenciar Funções Profissionais"
endpoint="/api/funcoes"
columns={[{ key: "nome", label: "Nome da Função" }]}
/>
)}
{activeTab === "precos" && (
<PriceTableEditor />
)}

View file

@ -966,7 +966,7 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({
};
const getEventsByRole = (userId: string, role: string) => {
if (role === "SUPERADMIN" || role === "BUSINESS_OWNER") {
if (role === "SUPERADMIN" || role === "BUSINESS_OWNER" || role === "RESEARCHER") {
return events;
}
if (role === "EVENT_OWNER") {

View file

@ -532,7 +532,7 @@ export const Dashboard: React.FC<DashboardProps> = ({
};
const renderRoleSpecificActions = () => {
if (user.role === UserRole.PHOTOGRAPHER || user.role === UserRole.AGENDA_VIEWER) return null;
if (user.role === UserRole.PHOTOGRAPHER || user.role === UserRole.AGENDA_VIEWER || user.role === UserRole.RESEARCHER) return null;
const label =
user.role === UserRole.EVENT_OWNER

View file

@ -23,7 +23,7 @@ const EventDetails: React.FC = () => {
if (!event) return <div className="p-8 text-center text-red-500">Evento não encontrado.</div>;
// Check if user can view logistics
const canViewLogistics = user?.role !== UserRole.AGENDA_VIEWER;
const canViewLogistics = user?.role !== UserRole.AGENDA_VIEWER && user?.role !== UserRole.RESEARCHER;
// Use event.date which is already YYYY-MM-DD from DataContext
const formattedDate = new Date(event.date + "T00:00:00").toLocaleDateString();

View file

@ -117,6 +117,8 @@ export const Login: React.FC<LoginProps> = ({ onNavigate }) => {
return "Fotógrafo";
case UserRole.EVENT_OWNER:
return "Cliente";
case UserRole.RESEARCHER:
return "Pesquisador";
default:
return role;
}
@ -267,6 +269,7 @@ export const Login: React.FC<LoginProps> = ({ onNavigate }) => {
{ id: "2", name: "PHOTUM CEO", email: "empresa@photum.com", role: UserRole.BUSINESS_OWNER },
{ id: "3", name: "COLABORADOR PHOTUM", email: "foto@photum.com", role: UserRole.PHOTOGRAPHER },
{ id: "4", name: "CLIENTE TESTE", email: "cliente@photum.com", role: UserRole.EVENT_OWNER },
{ id: "5", name: "PESQUISADOR", email: "pesquisa@photum.com", role: UserRole.RESEARCHER },
].map((user) => (
<button
key={user.id}

View file

@ -25,7 +25,7 @@ export const ProfessionalRegister: React.FC<ProfessionalRegisterProps> = ({
email: professionalData.email,
senha: professionalData.senha,
telefone: professionalData.whatsapp,
role: "PHOTOGRAPHER", // Role fixa para profissionais
role: professionalData.funcaoLabel?.toUpperCase().includes("PESQUISA") ? "RESEARCHER" : "PHOTOGRAPHER",
tipo_profissional: professionalData.funcaoLabel || "", // Envia o nome da função (ex: Cinegrafista)
});

View file

@ -348,7 +348,7 @@ export const TeamPage: React.FC = () => {
email: formData.email,
senha: formData.senha,
nome: formData.nome,
role: "PHOTOGRAPHER", // Default role for professionals created here? Or map from selected role?
role: roles.find(r => r.id === formData.funcao_profissional_id)?.nome.toUpperCase().includes("PESQUISA") ? "RESEARCHER" : "PHOTOGRAPHER",
// Mapear função? Usually PHOTOGRAPHER or generic. Let's assume PHOTOGRAPHER for now as they are "Equipe".
tipo_profissional: roles.find(r => r.id === formData.funcao_profissional_id)?.nome || "",
ativo: true, // Auto-active as per request

View file

@ -4,8 +4,9 @@ import {
getPendingUsers,
approveUser as apiApproveUser,
rejectUser as apiRejectUser,
updateUserRole,
} from "../services/apiService";
import { UserApprovalStatus } from "../types";
import { UserApprovalStatus, UserRole } from "../types";
import {
CheckCircle,
XCircle,
@ -14,6 +15,7 @@ import {
Filter,
Users,
Briefcase,
Edit2,
} from "lucide-react";
import { Button } from "../components/Button";
@ -43,15 +45,11 @@ export const UserApproval: React.FC<UserApprovalProps> = ({ onNavigate }) => {
try {
const result = await getPendingUsers(token);
if (result.data) {
// Mapear dados do backend para o formato esperado pelo componente, se necessário
// Supondo que o backend retorna estrutura compatível ou fazemos o map aqui
const mappedUsers = result.data.map((u: any) => ({
...u,
approvalStatus: u.ativo
? UserApprovalStatus.APPROVED
: UserApprovalStatus.PENDING, // Simplificação, backend deve retornar status real se houver rejected
// Se o backend não retornar status explícito, assumimos pendente se !ativo
// Mas idealmente o backend retornaria um status enum
: UserApprovalStatus.PENDING,
}));
setUsers(mappedUsers);
}
@ -71,7 +69,6 @@ export const UserApproval: React.FC<UserApprovalProps> = ({ onNavigate }) => {
setIsProcessing(userId);
try {
await apiApproveUser(userId, token);
// Atualizar lista após aprovação
await fetchUsers();
} catch (error) {
console.error("Erro ao aprovar usuário:", error);
@ -81,15 +78,31 @@ export const UserApproval: React.FC<UserApprovalProps> = ({ onNavigate }) => {
}
};
const handleRoleChange = async (userId: string, newRole: string) => {
if (!token) return;
try {
// Optimistic update
setUsers(prev => prev.map(u => u.id === userId ? {...u, role: newRole} : u));
await updateUserRole(userId, newRole, token);
// Refresh to be sure
// await fetchUsers(); // Optional if we trust optimistic
} catch (error) {
console.error("Erro ao atualizar role:", error);
alert("Erro ao atualizar função do usuário");
// Revert? simpler to just fetch
fetchUsers();
}
};
// Separar usuários Clientes (EVENT_OWNER) e Profissionais (PHOTOGRAPHER)
// Backend roles: PHOTOGRAPHER, EVENT_OWNER, BUSINESS_OWNER, SUPERADMIN
// Backend roles: PHOTOGRAPHER, EVENT_OWNER, BUSINESS_OWNER, SUPERADMIN, RESEARCHER
const clientUsers = users.filter(
(user) => user.role === "EVENT_OWNER"
);
const professionalUsers = users.filter(
(user) => user.role === "PHOTOGRAPHER"
(user) => user.role === "PHOTOGRAPHER" || user.role === "RESEARCHER" || user.role === "BUSINESS_OWNER" // Include BUSINESS_OWNER if relevant for professional list? Usually Business owner is client-side but maybe here it's treated differently. based on login.tsx business owner is "Dono do Negócio". Let's stick to PHOTOGRAPHER + RESEARCHER as per request, but user explicitly mentioned "admin ter liberdade de editar role".
);
// Filtrar usuários baseado na aba ativa
@ -99,20 +112,10 @@ export const UserApproval: React.FC<UserApprovalProps> = ({ onNavigate }) => {
const matchesSearch =
(user.name || "").toLowerCase().includes(searchTerm.toLowerCase()) ||
(user.email || "").toLowerCase().includes(searchTerm.toLowerCase());
// Remover filtro por registeredInstitution se não vier do backend ainda
// Por enquanto, como o backend retorna apenas pendentes na rota /pending (conforme nome da rota),
// o statusFilter pode ser redundante se a rota só traz pendentes.
// Mas se o backend trouxer todos, o filtro funciona.
// Se a rota for /users/pending, assumimos que todos são pendentes.
// VAMOS ASSUMIR QUE O BACKEND SÓ RETORNA PENDENTES POR ENQUANTO.
// Mas para manter a UI, vamos considerar todos como Pendentes se status não vier.
return matchesSearch;
});
const getStatusBadge = (status: UserApprovalStatus) => {
// Se status undefined, assume pendente para visualização
const s = status || UserApprovalStatus.PENDING;
switch (s) {
case UserApprovalStatus.PENDING:
@ -237,6 +240,10 @@ export const UserApproval: React.FC<UserApprovalProps> = ({ onNavigate }) => {
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Telefone
</th>
{/* Role Column */}
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Função
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Data de Cadastro
@ -253,7 +260,7 @@ export const UserApproval: React.FC<UserApprovalProps> = ({ onNavigate }) => {
{filteredUsers.length === 0 ? (
<tr>
<td
colSpan={activeTab === "cliente" ? 7 : 6}
colSpan={activeTab === "cliente" ? 8 : 7}
className="px-6 py-12 text-center text-gray-500"
>
<div className="flex flex-col items-center justify-center">
@ -298,6 +305,36 @@ export const UserApproval: React.FC<UserApprovalProps> = ({ onNavigate }) => {
{user.phone || "-"}
</div>
</td>
{/* Role Editor */}
<td className="px-6 py-4 whitespace-nowrap">
<select
value={
user.role === "PHOTOGRAPHER" && user.professional_type === "Cinegrafista" ? "Cinegrafista" :
user.role === "PHOTOGRAPHER" && user.professional_type === "Recepcionista" ? "Recepcionista" :
user.role === "PHOTOGRAPHER" && user.professional_type === "Fotógrafo" ? "Fotógrafo" :
user.role === "PHOTOGRAPHER" ? "Fotógrafo" : // Default to Fotógrafo if generic Photographer role
user.role
}
onChange={(e) => {
let newRole = e.target.value;
if (["Cinegrafista", "Recepcionista", "Fotógrafo"].includes(newRole)) {
newRole = "PHOTOGRAPHER";
// Note: We are currently only updating the System Role.
// The 'professional_type' field is not updated by updateUserRole endpoint.
// This UI allows the user to confirm the System Role is correct for these types.
}
handleRoleChange(user.id, newRole);
}}
className="text-sm border-gray-300 rounded-md shadow-sm focus:border-brand-gold focus:ring focus:ring-brand-gold focus:ring-opacity-50"
>
<option value="Fotógrafo">Fotógrafo</option>
<option value="Cinegrafista">Cinegrafista</option>
<option value="Recepcionista">Recepcionista</option>
<option value="RESEARCHER">Pesquisador</option>
<option value="EVENT_OWNER">Cliente</option>
<option value="BUSINESS_OWNER">Empresa</option>
</select>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-600">
{user.created_at

View file

@ -715,6 +715,40 @@ export async function rejectUser(userId: string, token: string): Promise<ApiResp
}
}
/**
* Atualiza a role de um usuário
*/
export async function updateUserRole(userId: string, role: string, token: string): Promise<ApiResponse<any>> {
try {
const response = await fetch(`${API_BASE_URL}/api/admin/users/${userId}/role`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
body: JSON.stringify({ role })
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return {
data,
error: null,
isBackendDown: false,
};
} catch (error) {
console.error("Error updating user role:", error);
return {
data: null,
error: error instanceof Error ? error.message : "Erro desconhecido",
isBackendDown: true,
};
}
}
/**
* Atribui um profissional a um evento
*/

View file

@ -4,6 +4,7 @@ export enum UserRole {
EVENT_OWNER = "EVENT_OWNER",
PHOTOGRAPHER = "PHOTOGRAPHER",
AGENDA_VIEWER = "AGENDA_VIEWER",
RESEARCHER = "RESEARCHER",
}
export enum UserApprovalStatus {