feat: Implementa validação de e-mail único e melhorias na aprovação de usuários
Backend: - Adiciona constraint UNIQUE para 'email' na tabela cadastro_profissionais. - Atualiza schema.sql para converter e-mails vazios para NULL automaticamente. - Modifica query CreateProfissional para usar ON CONFLICT (email) DO UPDATE (Upsert). - Ajusta helper toPgText para tratar string vazia como NULL, permitindo múltiplos profissionais sem e-mail. Frontend: - Adiciona Modal de Detalhes do Usuário na página de Aprovação. - Oculta seletor de função para usuários do tipo 'Cliente'.
This commit is contained in:
parent
ec2d96333f
commit
02309f74c0
5 changed files with 223 additions and 33 deletions
|
|
@ -46,7 +46,31 @@ INSERT INTO cadastro_profissionais (
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15,
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15,
|
||||||
$16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26
|
$16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26
|
||||||
) RETURNING id, usuario_id, nome, funcao_profissional_id, endereco, cidade, uf, whatsapp, cpf_cnpj_titular, banco, agencia, conta_pix, carro_disponivel, tem_estudio, qtd_estudio, tipo_cartao, observacao, qual_tec, educacao_simpatia, desempenho_evento, disp_horario, media, tabela_free, extra_por_equipamento, equipamentos, email, avatar_url, criado_em, atualizado_em
|
)
|
||||||
|
ON CONFLICT (email) DO UPDATE SET
|
||||||
|
nome = EXCLUDED.nome,
|
||||||
|
funcao_profissional_id = EXCLUDED.funcao_profissional_id,
|
||||||
|
whatsapp = EXCLUDED.whatsapp,
|
||||||
|
cpf_cnpj_titular = EXCLUDED.cpf_cnpj_titular,
|
||||||
|
banco = EXCLUDED.banco,
|
||||||
|
agencia = EXCLUDED.agencia,
|
||||||
|
conta_pix = EXCLUDED.conta_pix,
|
||||||
|
carro_disponivel = EXCLUDED.carro_disponivel,
|
||||||
|
tem_estudio = EXCLUDED.tem_estudio,
|
||||||
|
qtd_estudio = EXCLUDED.qtd_estudio,
|
||||||
|
tipo_cartao = EXCLUDED.tipo_cartao,
|
||||||
|
observacao = EXCLUDED.observacao,
|
||||||
|
qual_tec = EXCLUDED.qual_tec,
|
||||||
|
educacao_simpatia = EXCLUDED.educacao_simpatia,
|
||||||
|
desempenho_evento = EXCLUDED.desempenho_evento,
|
||||||
|
disp_horario = EXCLUDED.disp_horario,
|
||||||
|
media = EXCLUDED.media,
|
||||||
|
tabela_free = EXCLUDED.tabela_free,
|
||||||
|
extra_por_equipamento = EXCLUDED.extra_por_equipamento,
|
||||||
|
equipamentos = EXCLUDED.equipamentos,
|
||||||
|
avatar_url = EXCLUDED.avatar_url,
|
||||||
|
atualizado_em = NOW()
|
||||||
|
RETURNING id, usuario_id, nome, funcao_profissional_id, endereco, cidade, uf, whatsapp, cpf_cnpj_titular, banco, agencia, conta_pix, carro_disponivel, tem_estudio, qtd_estudio, tipo_cartao, observacao, qual_tec, educacao_simpatia, desempenho_evento, disp_horario, media, tabela_free, extra_por_equipamento, equipamentos, email, avatar_url, criado_em, atualizado_em
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateProfissionalParams struct {
|
type CreateProfissionalParams struct {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,31 @@ INSERT INTO cadastro_profissionais (
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15,
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15,
|
||||||
$16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26
|
$16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26
|
||||||
) RETURNING *;
|
)
|
||||||
|
ON CONFLICT (email) DO UPDATE SET
|
||||||
|
nome = EXCLUDED.nome,
|
||||||
|
funcao_profissional_id = EXCLUDED.funcao_profissional_id,
|
||||||
|
whatsapp = EXCLUDED.whatsapp,
|
||||||
|
cpf_cnpj_titular = EXCLUDED.cpf_cnpj_titular,
|
||||||
|
banco = EXCLUDED.banco,
|
||||||
|
agencia = EXCLUDED.agencia,
|
||||||
|
conta_pix = EXCLUDED.conta_pix,
|
||||||
|
carro_disponivel = EXCLUDED.carro_disponivel,
|
||||||
|
tem_estudio = EXCLUDED.tem_estudio,
|
||||||
|
qtd_estudio = EXCLUDED.qtd_estudio,
|
||||||
|
tipo_cartao = EXCLUDED.tipo_cartao,
|
||||||
|
observacao = EXCLUDED.observacao,
|
||||||
|
qual_tec = EXCLUDED.qual_tec,
|
||||||
|
educacao_simpatia = EXCLUDED.educacao_simpatia,
|
||||||
|
desempenho_evento = EXCLUDED.desempenho_evento,
|
||||||
|
disp_horario = EXCLUDED.disp_horario,
|
||||||
|
media = EXCLUDED.media,
|
||||||
|
tabela_free = EXCLUDED.tabela_free,
|
||||||
|
extra_por_equipamento = EXCLUDED.extra_por_equipamento,
|
||||||
|
equipamentos = EXCLUDED.equipamentos,
|
||||||
|
avatar_url = EXCLUDED.avatar_url,
|
||||||
|
atualizado_em = NOW()
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
-- name: GetProfissionalByUsuarioID :one
|
-- name: GetProfissionalByUsuarioID :one
|
||||||
SELECT p.*,
|
SELECT p.*,
|
||||||
|
|
|
||||||
|
|
@ -501,3 +501,14 @@ BEGIN
|
||||||
END IF;
|
END IF;
|
||||||
END $$;
|
END $$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
-- 1. Converter emails vazios para NULL para evitar erro de duplicidade em strings vazias
|
||||||
|
UPDATE cadastro_profissionais SET email = NULL WHERE email = '';
|
||||||
|
|
||||||
|
-- 2. Aplicar Constraint Única se não existir
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name='unique_email_profissional' AND table_name='cadastro_profissionais') THEN
|
||||||
|
ALTER TABLE cadastro_profissionais ADD CONSTRAINT unique_email_profissional UNIQUE (email);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -414,7 +414,7 @@ func (s *Service) Delete(ctx context.Context, id string) error {
|
||||||
// Helpers
|
// Helpers
|
||||||
|
|
||||||
func toPgText(s *string) pgtype.Text {
|
func toPgText(s *string) pgtype.Text {
|
||||||
if s == nil {
|
if s == nil || *s == "" {
|
||||||
return pgtype.Text{Valid: false}
|
return pgtype.Text{Valid: false}
|
||||||
}
|
}
|
||||||
return pgtype.Text{String: *s, Valid: true}
|
return pgtype.Text{String: *s, Valid: true}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ export const UserApproval: React.FC<UserApprovalProps> = ({ onNavigate }) => {
|
||||||
"cliente"
|
"cliente"
|
||||||
);
|
);
|
||||||
const [isProcessing, setIsProcessing] = useState<string | null>(null);
|
const [isProcessing, setIsProcessing] = useState<string | null>(null);
|
||||||
|
const [selectedUser, setSelectedUser] = useState<any | null>(null);
|
||||||
|
|
||||||
const fetchUsers = async () => {
|
const fetchUsers = async () => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
|
|
@ -144,6 +145,134 @@ export const UserApproval: React.FC<UserApprovalProps> = ({ onNavigate }) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Modal Component
|
||||||
|
const UserDetailsModal = () => {
|
||||||
|
if (!selectedUser) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4 fade-in">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-lg overflow-hidden animate-slide-up">
|
||||||
|
<div className="flex justify-between items-center p-6 border-b border-gray-100">
|
||||||
|
<h3 className="text-xl font-bold text-gray-900 font-serif">
|
||||||
|
Detalhes do Cadastro
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedUser(null)}
|
||||||
|
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
<XCircle className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||||
|
Nome
|
||||||
|
</label>
|
||||||
|
<p className="mt-1 text-base text-gray-900 font-medium">
|
||||||
|
{selectedUser.name || "-"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<p className="mt-1 text-base text-gray-900">
|
||||||
|
{selectedUser.email || "-"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||||
|
Telefone
|
||||||
|
</label>
|
||||||
|
<p className="mt-1 text-base text-gray-900">
|
||||||
|
{selectedUser.phone || "-"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||||
|
Data Cadastro
|
||||||
|
</label>
|
||||||
|
<p className="mt-1 text-base text-gray-900">
|
||||||
|
{selectedUser.created_at ? new Date(selectedUser.created_at).toLocaleDateString("pt-BR") : "-"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedUser.role === "EVENT_OWNER" && (
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="block text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||||
|
Empresa Vinculada
|
||||||
|
</label>
|
||||||
|
<p className="mt-1 text-base text-gray-900 font-medium">
|
||||||
|
{selectedUser.company_name || "-"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4 border-t border-gray-100">
|
||||||
|
<label className="block text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">
|
||||||
|
Função / Cargo
|
||||||
|
</label>
|
||||||
|
{selectedUser.role === "EVENT_OWNER" ? (
|
||||||
|
<span className="inline-block px-3 py-1 bg-brand-gold/10 text-brand-gold rounded-full text-sm font-medium">
|
||||||
|
Cliente (Empresa)
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<select
|
||||||
|
value={
|
||||||
|
selectedUser.role === "PHOTOGRAPHER" && selectedUser.professional_type === "Cinegrafista" ? "Cinegrafista" :
|
||||||
|
selectedUser.role === "PHOTOGRAPHER" && selectedUser.professional_type === "Recepcionista" ? "Recepcionista" :
|
||||||
|
selectedUser.role === "PHOTOGRAPHER" && selectedUser.professional_type === "Fotógrafo" ? "Fotógrafo" :
|
||||||
|
selectedUser.role === "PHOTOGRAPHER" ? "Fotógrafo" :
|
||||||
|
selectedUser.role
|
||||||
|
}
|
||||||
|
onChange={(e) => {
|
||||||
|
let newRole = e.target.value;
|
||||||
|
if (["Cinegrafista", "Recepcionista", "Fotógrafo"].includes(newRole)) {
|
||||||
|
newRole = "PHOTOGRAPHER";
|
||||||
|
}
|
||||||
|
// Update local selected user state optimistic
|
||||||
|
setSelectedUser({...selectedUser, role: newRole});
|
||||||
|
handleRoleChange(selectedUser.id, newRole);
|
||||||
|
}}
|
||||||
|
className="w-full text-sm border-gray-300 rounded-md shadow-sm focus:border-brand-gold focus:ring focus:ring-brand-gold focus:ring-opacity-50 p-2"
|
||||||
|
>
|
||||||
|
<option value="Fotógrafo">Fotógrafo</option>
|
||||||
|
<option value="Cinegrafista">Cinegrafista</option>
|
||||||
|
<option value="Recepcionista">Recepcionista</option>
|
||||||
|
<option value="RESEARCHER">Pesquisador</option>
|
||||||
|
<option value="BUSINESS_OWNER">Empresa</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 bg-gray-50 flex justify-end gap-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setSelectedUser(null)}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
handleApprove(selectedUser.id);
|
||||||
|
setSelectedUser(null);
|
||||||
|
}}
|
||||||
|
isLoading={isProcessing === selectedUser.id}
|
||||||
|
>
|
||||||
|
<CheckCircle className="w-4 h-4 mr-2" />
|
||||||
|
Aprovar Cadastro
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
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="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">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
|
@ -281,7 +410,8 @@ export const UserApproval: React.FC<UserApprovalProps> = ({ onNavigate }) => {
|
||||||
filteredUsers.map((user, index) => (
|
filteredUsers.map((user, index) => (
|
||||||
<tr
|
<tr
|
||||||
key={`${user.id}-${index}`}
|
key={`${user.id}-${index}`}
|
||||||
className="hover:bg-gray-50 transition-colors"
|
className="hover:bg-gray-50 transition-colors cursor-pointer"
|
||||||
|
onClick={() => setSelectedUser(user)}
|
||||||
>
|
>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="text-sm font-medium text-gray-900">
|
<div className="text-sm font-medium text-gray-900">
|
||||||
|
|
@ -306,34 +436,34 @@ export const UserApproval: React.FC<UserApprovalProps> = ({ onNavigate }) => {
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
{/* Role Editor */}
|
{/* Role Editor */}
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap" onClick={(e) => e.stopPropagation()}>
|
||||||
<select
|
{activeTab === "cliente" ? (
|
||||||
value={
|
<span className="text-sm text-gray-900">Cliente</span>
|
||||||
user.role === "PHOTOGRAPHER" && user.professional_type === "Cinegrafista" ? "Cinegrafista" :
|
) : (
|
||||||
user.role === "PHOTOGRAPHER" && user.professional_type === "Recepcionista" ? "Recepcionista" :
|
<select
|
||||||
user.role === "PHOTOGRAPHER" && user.professional_type === "Fotógrafo" ? "Fotógrafo" :
|
value={
|
||||||
user.role === "PHOTOGRAPHER" ? "Fotógrafo" : // Default to Fotógrafo if generic Photographer role
|
user.role === "PHOTOGRAPHER" && user.professional_type === "Cinegrafista" ? "Cinegrafista" :
|
||||||
user.role
|
user.role === "PHOTOGRAPHER" && user.professional_type === "Recepcionista" ? "Recepcionista" :
|
||||||
}
|
user.role === "PHOTOGRAPHER" && user.professional_type === "Fotógrafo" ? "Fotógrafo" :
|
||||||
onChange={(e) => {
|
user.role === "PHOTOGRAPHER" ? "Fotógrafo" :
|
||||||
let newRole = e.target.value;
|
user.role
|
||||||
if (["Cinegrafista", "Recepcionista", "Fotógrafo"].includes(newRole)) {
|
}
|
||||||
newRole = "PHOTOGRAPHER";
|
onChange={(e) => {
|
||||||
// Note: We are currently only updating the System Role.
|
let newRole = e.target.value;
|
||||||
// The 'professional_type' field is not updated by updateUserRole endpoint.
|
if (["Cinegrafista", "Recepcionista", "Fotógrafo"].includes(newRole)) {
|
||||||
// This UI allows the user to confirm the System Role is correct for these types.
|
newRole = "PHOTOGRAPHER";
|
||||||
}
|
}
|
||||||
handleRoleChange(user.id, newRole);
|
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"
|
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="Fotógrafo">Fotógrafo</option>
|
||||||
<option value="Cinegrafista">Cinegrafista</option>
|
<option value="Cinegrafista">Cinegrafista</option>
|
||||||
<option value="Recepcionista">Recepcionista</option>
|
<option value="Recepcionista">Recepcionista</option>
|
||||||
<option value="RESEARCHER">Pesquisador</option>
|
<option value="RESEARCHER">Pesquisador</option>
|
||||||
<option value="EVENT_OWNER">Cliente</option>
|
<option value="BUSINESS_OWNER">Empresa</option>
|
||||||
<option value="BUSINESS_OWNER">Empresa</option>
|
</select>
|
||||||
</select>
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="text-sm text-gray-600">
|
<div className="text-sm text-gray-600">
|
||||||
|
|
@ -349,7 +479,7 @@ export const UserApproval: React.FC<UserApprovalProps> = ({ onNavigate }) => {
|
||||||
user.approvalStatus || UserApprovalStatus.PENDING
|
user.approvalStatus || UserApprovalStatus.PENDING
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -373,6 +503,7 @@ export const UserApproval: React.FC<UserApprovalProps> = ({ onNavigate }) => {
|
||||||
}
|
}
|
||||||
</div >
|
</div >
|
||||||
</div >
|
</div >
|
||||||
|
<UserDetailsModal />
|
||||||
</div >
|
</div >
|
||||||
</div >
|
</div >
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue