feat: habilita edição de perfil para clientes e corrige carga de dados

Backend:
- Adiciona endpoint `PUT /api/me` para permitir atualização de dados do usuário logado.
- Implementa query `UpdateCadastroCliente` e função de serviço [UpdateClientData]para persistir alterações de clientes.
- Atualiza handlers [Me], [Login] e [ListPending] para incluir e mapear corretamente campos de cliente (CPF, Endereço, Telefone).
- Corrige mapeamento do campo `phone` na struct de resposta do usuário.

Frontend:
- Habilita o formulário de edição em [Profile.tsx] para usuários do tipo 'CLIENTE' (Event Owner).
- Adiciona função [updateUserProfile] em [apiService.ts] para consumir o novo endpoint.
- Atualiza [AuthContext] para persistir campos do cliente (CPF, Endereço, etc.) durante a restauração de sessão ([restoreSession], corrigindo o bug de perfil vazio ao recarregar a página.
- Padroniza envio de dados no Registro e Aprovação para usar `snake_case` (ex: `cpf_cnpj`, `professional_type`).
- Atualiza tipos em [types.ts] para incluir campos de endereço e documentos.
This commit is contained in:
NANDO9322 2026-02-09 00:56:09 -03:00
parent 788e0dca70
commit 9c6ee3afdb
16 changed files with 981 additions and 168 deletions

View file

@ -173,6 +173,7 @@ func main() {
api.Use(auth.AuthMiddleware(cfg)) api.Use(auth.AuthMiddleware(cfg))
{ {
api.GET("/me", authHandler.Me) api.GET("/me", authHandler.Me)
api.PUT("/me", authHandler.UpdateMe)
profGroup := api.Group("/profissionais") profGroup := api.Group("/profissionais")
{ {

View file

@ -56,12 +56,20 @@ func (h *Handler) GetUploadURL(c *gin.Context) {
type registerRequest struct { type registerRequest struct {
Email string `json:"email" binding:"required,email"` Email string `json:"email" binding:"required,email"`
Senha string `json:"senha" binding:"required,min=6"` Senha string `json:"senha" binding:"required,min=6"`
Role string `json:"role" binding:"required"`
Nome string `json:"nome" binding:"required"` Nome string `json:"nome" binding:"required"`
Telefone string `json:"telefone"` Telefone string `json:"telefone"`
Role string `json:"role" binding:"required"` TipoProfissional string `json:"professional_type"`
EmpresaID string `json:"empresa_id"` EmpresaID string `json:"empresa_id"`
TipoProfissional string `json:"tipo_profissional"` // New field Regiao string `json:"regiao"`
Regiao string `json:"regiao"` // Optional: for AdminCreateUser override CpfCnpj string `json:"cpf_cnpj"`
Cep string `json:"cep"`
Endereco string `json:"endereco"`
Numero string `json:"numero"`
Complemento string `json:"complemento"`
Bairro string `json:"bairro"`
Cidade string `json:"cidade"`
Estado string `json:"estado"`
} }
// Register godoc // Register godoc
@ -105,7 +113,7 @@ func (h *Handler) Register(c *gin.Context) {
regiao = "SP" regiao = "SP"
} }
user, err := h.service.Register(c.Request.Context(), req.Email, req.Senha, req.Role, req.Nome, req.Telefone, req.TipoProfissional, empresaIDPtr, profData, regiao) user, err := h.service.Register(c.Request.Context(), req.Email, req.Senha, req.Role, req.Nome, req.Telefone, req.TipoProfissional, empresaIDPtr, profData, regiao, req.CpfCnpj, req.Cep, req.Endereco, req.Numero, req.Complemento, req.Bairro, req.Cidade, req.Estado)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "duplicate key") { if strings.Contains(err.Error(), "duplicate key") {
c.JSON(http.StatusConflict, gin.H{"error": "email already registered"}) c.JSON(http.StatusConflict, gin.H{"error": "email already registered"})
@ -177,6 +185,14 @@ type userResponse struct {
CompanyID string `json:"company_id,omitempty"` CompanyID string `json:"company_id,omitempty"`
CompanyName string `json:"company_name,omitempty"` CompanyName string `json:"company_name,omitempty"`
AllowedRegions []string `json:"allowed_regions"` AllowedRegions []string `json:"allowed_regions"`
CpfCnpj string `json:"cpf_cnpj,omitempty"`
Cep string `json:"cep,omitempty"`
Endereco string `json:"endereco,omitempty"`
Numero string `json:"numero,omitempty"`
Complemento string `json:"complemento,omitempty"`
Bairro string `json:"bairro,omitempty"`
Cidade string `json:"cidade,omitempty"`
Estado string `json:"estado,omitempty"`
} }
// Login godoc // Login godoc
@ -233,22 +249,66 @@ func (h *Handler) Login(c *gin.Context) {
companyName = user.EmpresaNome.String companyName = user.EmpresaNome.String
} }
resp := loginResponse{ // Prepare response
AccessToken: tokenPair.AccessToken, uResp := userResponse{
ExpiresAt: "2025-...", // logic to calculate if needed, or remove field ID: uuid.UUID(user.ID.Bytes).String(),
User: userResponse{ Email: user.Email,
ID: uuid.UUID(user.ID.Bytes).String(), Role: user.Role,
Email: user.Email, Ativo: user.Ativo, // Added this back from original
Role: user.Role, Name: user.Nome,
Ativo: user.Ativo, CompanyID: companyID,
Name: user.Nome, CompanyName: companyName,
Phone: user.Whatsapp, AllowedRegions: user.RegioesPermitidas,
CompanyID: companyID, Phone: "",
CompanyName: companyName,
AllowedRegions: user.RegioesPermitidas,
},
} }
// Helper to get phone from profData or Client Data
if user.Role == "PHOTOGRAPHER" && profData != nil {
if profData.Whatsapp.Valid {
uResp.Phone = profData.Whatsapp.String
}
} else if user.Role == "EVENT_OWNER" {
// Fetch Client Data
clientData, err := h.service.GetClientData(c.Request.Context(), uResp.ID)
if err == nil && clientData != nil {
if clientData.Telefone.Valid {
uResp.Phone = clientData.Telefone.String
}
if clientData.CpfCnpj.Valid {
uResp.CpfCnpj = clientData.CpfCnpj.String
}
if clientData.Cep.Valid {
uResp.Cep = clientData.Cep.String
}
if clientData.Endereco.Valid {
uResp.Endereco = clientData.Endereco.String
}
if clientData.Numero.Valid {
uResp.Numero = clientData.Numero.String
}
if clientData.Complemento.Valid {
uResp.Complemento = clientData.Complemento.String
}
if clientData.Bairro.Valid {
uResp.Bairro = clientData.Bairro.String
}
if clientData.Cidade.Valid {
uResp.Cidade = clientData.Cidade.String
}
if clientData.Estado.Valid {
uResp.Estado = clientData.Estado.String
}
// Use client name if available ?? Or user name is fine.
// Usually user.Name comes from `usuarios` table, but `cadastro_clientes` also has nome.
// Let's stick to user.Name for consistency, usually they should be same.
}
}
resp := loginResponse{
AccessToken: tokenPair.AccessToken,
ExpiresAt: "2025-...",
User: uResp,
}
if profData != nil { if profData != nil {
resp.Profissional = map[string]interface{}{ resp.Profissional = map[string]interface{}{
"id": uuid.UUID(profData.ID.Bytes).String(), "id": uuid.UUID(profData.ID.Bytes).String(),
@ -368,38 +428,76 @@ func (h *Handler) Me(c *gin.Context) {
allowedRegions = append(allowedRegions, user.RegioesPermitidas...) allowedRegions = append(allowedRegions, user.RegioesPermitidas...)
} }
uResp := userResponse{
ID: uuid.UUID(user.ID.Bytes).String(),
Email: user.Email,
Role: user.Role,
Ativo: user.Ativo,
Name: user.Nome,
Phone: user.Whatsapp, // Default to user.Whatsapp
CompanyName: empresaNome,
CompanyID: empresaID,
AllowedRegions: allowedRegions,
}
if user.Role == "EVENT_OWNER" {
clientData, err := h.service.GetClientData(c.Request.Context(), uResp.ID)
if err == nil && clientData != nil {
if clientData.Telefone.Valid {
uResp.Phone = clientData.Telefone.String
}
if clientData.CpfCnpj.Valid {
uResp.CpfCnpj = clientData.CpfCnpj.String
}
if clientData.Cep.Valid {
uResp.Cep = clientData.Cep.String
}
if clientData.Endereco.Valid {
uResp.Endereco = clientData.Endereco.String
}
if clientData.Numero.Valid {
uResp.Numero = clientData.Numero.String
}
if clientData.Complemento.Valid {
uResp.Complemento = clientData.Complemento.String
}
if clientData.Bairro.Valid {
uResp.Bairro = clientData.Bairro.String
}
if clientData.Cidade.Valid {
uResp.Cidade = clientData.Cidade.String
}
if clientData.Estado.Valid {
uResp.Estado = clientData.Estado.String
}
}
}
resp := loginResponse{ resp := loginResponse{
User: userResponse{ User: uResp,
ID: uuid.UUID(user.ID.Bytes).String(),
Email: user.Email,
Role: user.Role,
Ativo: user.Ativo,
Name: user.Nome,
Phone: user.Whatsapp,
CompanyName: empresaNome,
CompanyID: empresaID,
AllowedRegions: allowedRegions,
},
} }
if user.Role == "PHOTOGRAPHER" || user.Role == "BUSINESS_OWNER" { if user.Role == "PHOTOGRAPHER" || user.Role == "BUSINESS_OWNER" {
regiao := c.GetString("regiao") regiao := c.GetString("regiao")
// If regiao is empty, we might skip fetching professional data or default? if regiao == "" {
// For now if empty, GetProfessionalByUserID with valid=true and string="" will likely fail or return empty? regiao = c.GetHeader("x-regiao")
// Queries check regiao = $2. If regiao is "", and DB has "SP", it won't match. }
// So user needs to send header for Me to see pro data.
if regiao != "" { profData, err := h.service.GetProfessionalByUserID(c.Request.Context(), uuid.UUID(user.ID.Bytes).String())
profData, err := h.service.GetProfessionalByUserID(c.Request.Context(), uuid.UUID(user.ID.Bytes).String()) if err == nil && profData != nil {
if err == nil && profData != nil { // Update phone from professional data if valid
resp.Profissional = map[string]interface{}{ if profData.Whatsapp.Valid {
"id": uuid.UUID(profData.ID.Bytes).String(), resp.User.Phone = profData.Whatsapp.String
"nome": profData.Nome, }
"funcao_profissional_id": uuid.UUID(profData.FuncaoProfissionalID.Bytes).String(),
"funcao_profissional": "", // Deprecated resp.Profissional = map[string]interface{}{
"functions": profData.Functions, "id": uuid.UUID(profData.ID.Bytes).String(),
"equipamentos": profData.Equipamentos.String, "nome": profData.Nome,
"avatar_url": profData.AvatarUrl.String, "funcao_profissional_id": uuid.UUID(profData.FuncaoProfissionalID.Bytes).String(),
} "funcao_profissional": "", // Deprecated
"functions": profData.Functions,
"equipamentos": profData.Equipamentos.String,
"avatar_url": profData.AvatarUrl.String,
} }
} }
} }
@ -407,6 +505,75 @@ func (h *Handler) Me(c *gin.Context) {
c.JSON(http.StatusOK, resp) c.JSON(http.StatusOK, resp)
} }
type updateMeRequest struct {
Nome string `json:"name"`
Telefone string `json:"phone"`
CpfCnpj string `json:"cpf_cnpj"`
Cep string `json:"cep"`
Endereco string `json:"endereco"`
Numero string `json:"numero"`
Complemento string `json:"complemento"`
Bairro string `json:"bairro"`
Cidade string `json:"cidade"`
Estado string `json:"estado"`
}
// UpdateMe godoc
// @Summary Update current user profile
// @Description Update profile information (Name, Phone, Address). Currently for EVENT_OWNER.
// @Tags auth
// @Accept json
// @Produce json
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/me [put]
func (h *Handler) UpdateMe(c *gin.Context) {
userID, exists := c.Get("userID")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
var req updateMeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload: " + err.Error()})
return
}
// For now, we assume this is mostly for Clients (EVENT_OWNER)
// based on the fields provided (address, cpf, etc).
// Future: Check role and call appropriate service if needed.
// But UpdateClientData uses "UpdateCadastroCliente" which is linked to user_id.
// If the user does not have a client record, it might fail or do nothing if query uses WHERE exists?
// The query uses UPDATE, so if no row, no update.
// We should probably check role or just try to update.
// But to be safe, let's just call UpdateClientData.
// If the user is a Photographer, they should use the /profissionais/me PUT (if exists) or similar.
// But wait, the user complaint is about Clients.
err := h.service.UpdateClientData(c.Request.Context(), userID.(string), UpdateClientInput{
Nome: req.Nome,
Telefone: req.Telefone,
CpfCnpj: req.CpfCnpj,
Cep: req.Cep,
Endereco: req.Endereco,
Numero: req.Numero,
Complemento: req.Complemento,
Bairro: req.Bairro,
Cidade: req.Cidade,
Estado: req.Estado,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update profile: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "profile updated successfully"})
}
// ListPending godoc // ListPending godoc
// @Summary List pending users // @Summary List pending users
// @Description List users with ativo=false // @Description List users with ativo=false
@ -525,7 +692,7 @@ func (h *Handler) AdminCreateUser(c *gin.Context) {
// Let's assume header is present or default. // Let's assume header is present or default.
regiao = "SP" // Default for now if missing? Or error? regiao = "SP" // Default for now if missing? Or error?
} }
user, err := h.service.AdminCreateUser(c.Request.Context(), req.Email, req.Senha, req.Role, req.Nome, req.TipoProfissional, true, regiao) user, err := h.service.AdminCreateUser(c.Request.Context(), req.Email, req.Senha, req.Role, req.Nome, req.TipoProfissional, true, regiao, req.EmpresaID, req.Telefone, req.CpfCnpj, req.Cep, req.Endereco, req.Numero, req.Complemento, req.Bairro, req.Cidade, req.Estado)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "duplicate key") { if strings.Contains(err.Error(), "duplicate key") {
c.JSON(http.StatusConflict, gin.H{"error": "email already registered"}) c.JSON(http.StatusConflict, gin.H{"error": "email already registered"})
@ -620,7 +787,11 @@ func (h *Handler) DeleteUser(c *gin.Context) {
// @Security BearerAuth // @Security BearerAuth
// @Router /api/admin/users [get] // @Router /api/admin/users [get]
func (h *Handler) ListUsers(c *gin.Context) { func (h *Handler) ListUsers(c *gin.Context) {
users, err := h.service.ListUsers(c.Request.Context()) regiao := c.GetString("regiao")
if regiao == "" {
regiao = c.GetHeader("x-regiao")
}
users, err := h.service.ListUsers(c.Request.Context(), regiao)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return

View file

@ -13,6 +13,7 @@ import (
"photum-backend/internal/profissionais" "photum-backend/internal/profissionais"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@ -46,7 +47,7 @@ func NewService(queries *generated.Queries, profissionaisService *profissionais.
} }
} }
func (s *Service) Register(ctx context.Context, email, senha, role, nome, telefone, tipoProfissional string, empresaID *string, profissionalData *profissionais.CreateProfissionalInput, regiao string) (*generated.Usuario, error) { func (s *Service) Register(ctx context.Context, email, senha, role, nome, telefone, tipoProfissional string, empresaID *string, profissionalData *profissionais.CreateProfissionalInput, regiao, cpfCnpj, cep, endereco, numero, complemento, bairro, cidade, estado string) (*generated.Usuario, error) {
// Hash password // Hash password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(senha), bcrypt.DefaultCost) hashedPassword, err := bcrypt.GenerateFromPassword([]byte(senha), bcrypt.DefaultCost)
if err != nil { if err != nil {
@ -93,21 +94,27 @@ func (s *Service) Register(ctx context.Context, email, senha, role, nome, telefo
// If role is 'EVENT_OWNER', create client profile // If role is 'EVENT_OWNER', create client profile
if role == RoleEventOwner { if role == RoleEventOwner {
userID := user.ID
var empID pgtype.UUID var empID pgtype.UUID
if empresaID != nil && *empresaID != "" { if empresaID != nil && *empresaID != "" {
parsedEmpID, err := uuid.Parse(*empresaID) parsedEmpID, err := uuid.Parse(*empresaID)
if err == nil { if err == nil {
empID.Bytes = parsedEmpID empID = pgtype.UUID{Bytes: parsedEmpID, Valid: true}
empID.Valid = true
} }
} }
_, err := s.queries.CreateCadastroCliente(ctx, generated.CreateCadastroClienteParams{ _, err = s.queries.CreateCadastroCliente(ctx, generated.CreateCadastroClienteParams{
UsuarioID: userID, UsuarioID: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
EmpresaID: empID, EmpresaID: empID,
Nome: pgtype.Text{String: nome, Valid: nome != ""}, Nome: pgtype.Text{String: nome, Valid: true},
Telefone: pgtype.Text{String: telefone, Valid: telefone != ""}, Telefone: pgtype.Text{String: telefone, Valid: telefone != ""},
CpfCnpj: toPgText(&cpfCnpj),
Cep: toPgText(&cep),
Endereco: toPgText(&endereco),
Numero: toPgText(&numero),
Complemento: toPgText(&complemento),
Bairro: toPgText(&bairro),
Cidade: toPgText(&cidade),
Estado: toPgText(&estado),
}) })
if err != nil { if err != nil {
_ = s.queries.DeleteUsuario(ctx, user.ID) _ = s.queries.DeleteUsuario(ctx, user.ID)
@ -218,7 +225,7 @@ func (s *Service) ApproveUser(ctx context.Context, id string) error {
return err return err
} }
func (s *Service) AdminCreateUser(ctx context.Context, email, senha, role, nome, tipoProfissional string, ativo bool, regiao string) (*generated.Usuario, error) { func (s *Service) AdminCreateUser(ctx context.Context, email, senha, role, nome, tipoProfissional string, ativo bool, regiao string, empresaID string, telefone, cpfCnpj, cep, endereco, numero, complemento, bairro, cidade, estado string) (*generated.Usuario, error) {
// Hash password // Hash password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(senha), bcrypt.DefaultCost) hashedPassword, err := bcrypt.GenerateFromPassword([]byte(senha), bcrypt.DefaultCost)
if err != nil { if err != nil {
@ -246,14 +253,10 @@ func (s *Service) AdminCreateUser(ctx context.Context, email, senha, role, nome,
_ = s.queries.DeleteUsuario(ctx, user.ID) _ = s.queries.DeleteUsuario(ctx, user.ID)
return nil, err return nil, err
} }
// Update the user struct in memory too (important for the return value?)
// user.RegioesPermitidas = []string{regiao} // Generated struct might differ, but return value is pointer to user.
// Since we return &user, and user is local struct from CreateUsuario, it has empty RegioesPermitidas.
// It's better to manually updating it if downstream depends on it, but usually ID/Email is enough.
} }
if ativo { if ativo {
// Approve user immediately (already active=true by default in DB? No, default false) // Approve user immediately
err = s.ApproveUser(ctx, uuid.UUID(user.ID.Bytes).String()) err = s.ApproveUser(ctx, uuid.UUID(user.ID.Bytes).String())
if err != nil { if err != nil {
_ = s.queries.DeleteUsuario(ctx, user.ID) _ = s.queries.DeleteUsuario(ctx, user.ID)
@ -262,6 +265,33 @@ func (s *Service) AdminCreateUser(ctx context.Context, email, senha, role, nome,
user.Ativo = true user.Ativo = true
} }
// Link Company if EVENT_OWNER
if role == RoleEventOwner && empresaID != "" {
empUuid, err := uuid.Parse(empresaID)
if err == nil {
_, err = s.queries.CreateCadastroCliente(ctx, generated.CreateCadastroClienteParams{
UsuarioID: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
EmpresaID: pgtype.UUID{Bytes: empUuid, Valid: true},
Nome: pgtype.Text{String: nome, Valid: true},
Telefone: toPgText(&telefone),
CpfCnpj: toPgText(&cpfCnpj),
Cep: toPgText(&cep),
Endereco: toPgText(&endereco),
Numero: toPgText(&numero),
Complemento: toPgText(&complemento),
Bairro: toPgText(&bairro),
Cidade: toPgText(&cidade),
Estado: toPgText(&estado),
})
if err != nil {
// Log error but maybe don't fail user creation?
// Ideally rollback
_ = s.queries.DeleteUsuario(ctx, user.ID)
return nil, err
}
}
}
// Create professional profile if applicable // Create professional profile if applicable
if role == RolePhotographer || role == RoleBusinessOwner { if role == RolePhotographer || role == RoleBusinessOwner {
var funcaoID string var funcaoID string
@ -285,22 +315,11 @@ func (s *Service) AdminCreateUser(ctx context.Context, email, senha, role, nome,
Nome: nome, Nome: nome,
Email: &email, Email: &email,
FuncaoProfissionalID: funcaoID, FuncaoProfissionalID: funcaoID,
// Add default whatsapp if user has one? User struct doesn't have phone here passed in args,
// but we can pass it if we update signature or just leave empty.
} }
// Create the professional // Create the professional
_, err = s.profissionaisService.Create(ctx, uuid.UUID(user.ID.Bytes).String(), profInput, regiao) _, err = s.profissionaisService.Create(ctx, uuid.UUID(user.ID.Bytes).String(), profInput, regiao)
if err != nil { if err != nil {
// Log error but don't fail user creation?
// Better to log. Backend logs not setup here.
// Just continue for now, or return error?
// If we fail here, user exists but has no profile.
// Ideally we should delete user or return error.
// Let's log and ignore for now to avoid breaking legacy flows if any.
// Actually, if this fails, the bug persists. Best to return error.
// But since we already committed user, we should probably return error so client knows.
// Try to delete user to rollback // Try to delete user to rollback
_ = s.queries.DeleteUsuario(ctx, user.ID) _ = s.queries.DeleteUsuario(ctx, user.ID)
return nil, err return nil, err
@ -367,20 +386,25 @@ func (s *Service) EnsureDemoUsers(ctx context.Context) error {
if err != nil { if err != nil {
fmt.Printf("[DEBUG] Creating User %s\n", u.Email) fmt.Printf("[DEBUG] Creating User %s\n", u.Email)
// User not found (or error), try to create // Create if not exists
// Note: We use "SP" as default regiao for professional creation if errors.Is(err, pgx.ErrNoRows) { // Only create if user not found
user, err := s.AdminCreateUser(ctx, u.Email, "123456", u.Role, u.Name, "", true, "SP") fmt.Printf("Creating demo user: %s (%s)\n", u.Email, u.Role)
if err != nil { // Pass empty strings for new client fields for demo users
fmt.Printf("[DEBUG] Error creating user %s: %v\n", u.Email, err) user, err := s.AdminCreateUser(ctx, u.Email, "123456", u.Role, u.Name, "", true, regions[0], "", "", "", "", "", "", "", "", "", "")
return err if err != nil {
} fmt.Printf("Error creating demo user %s: %v\n", u.Email, err)
// Update to include specific regions return err
err = s.queries.UpdateUsuarioRegions(ctx, generated.UpdateUsuarioRegionsParams{ }
ID: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true}, // Update to include specific regions
RegioesPermitidas: regions, err = s.queries.UpdateUsuarioRegions(ctx, generated.UpdateUsuarioRegionsParams{
}) ID: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
if err != nil { RegioesPermitidas: regions,
fmt.Printf("[DEBUG] Error updating regions for new user %s: %v\n", u.Email, err) })
if err != nil {
fmt.Printf("[DEBUG] Error updating regions for new user %s: %v\n", u.Email, err)
return err
}
} else { // If it's another error, return it
return err return err
} }
} else { } else {
@ -422,8 +446,46 @@ func (s *Service) EnsureDemoUsers(ctx context.Context) error {
return nil return nil
} }
func (s *Service) ListUsers(ctx context.Context) ([]generated.ListAllUsuariosRow, error) { type UpdateClientInput struct {
return s.queries.ListAllUsuarios(ctx) Nome string
Telefone string
CpfCnpj string
Cep string
Endereco string
Numero string
Complemento string
Bairro string
Cidade string
Estado string
}
func (s *Service) UpdateClientData(ctx context.Context, userID string, input UpdateClientInput) error {
uid, err := uuid.Parse(userID)
if err != nil {
return err
}
// Prepare params
params := generated.UpdateCadastroClienteParams{
UsuarioID: pgtype.UUID{Bytes: uid, Valid: true},
Nome: pgtype.Text{String: input.Nome, Valid: input.Nome != ""},
Telefone: pgtype.Text{String: input.Telefone, Valid: input.Telefone != ""},
CpfCnpj: pgtype.Text{String: input.CpfCnpj, Valid: input.CpfCnpj != ""},
Cep: pgtype.Text{String: input.Cep, Valid: input.Cep != ""},
Endereco: pgtype.Text{String: input.Endereco, Valid: input.Endereco != ""},
Numero: pgtype.Text{String: input.Numero, Valid: input.Numero != ""},
Complemento: pgtype.Text{String: input.Complemento, Valid: input.Complemento != ""},
Bairro: pgtype.Text{String: input.Bairro, Valid: input.Bairro != ""},
Cidade: pgtype.Text{String: input.Cidade, Valid: input.Cidade != ""},
Estado: pgtype.Text{String: input.Estado, Valid: input.Estado != ""},
}
_, err = s.queries.UpdateCadastroCliente(ctx, params)
return err
}
func (s *Service) ListUsers(ctx context.Context, regiao string) ([]generated.ListAllUsuariosRow, error) {
return s.queries.ListAllUsuarios(ctx, regiao)
} }
func (s *Service) GetUser(ctx context.Context, id string) (*generated.GetUsuarioByIDRow, error) { func (s *Service) GetUser(ctx context.Context, id string) (*generated.GetUsuarioByIDRow, error) {
@ -458,6 +520,22 @@ func (s *Service) GetProfessionalByUserID(ctx context.Context, userID string) (*
return &p, nil return &p, nil
} }
func (s *Service) GetClientData(ctx context.Context, userID string) (*generated.CadastroCliente, error) {
parsedUUID, err := uuid.Parse(userID)
if err != nil {
return nil, err
}
var pgID pgtype.UUID
pgID.Bytes = parsedUUID
pgID.Valid = true
c, err := s.queries.GetCadastroClienteByUsuarioID(ctx, pgID)
if err != nil {
return nil, err
}
return &c, nil
}
func toPgText(s *string) pgtype.Text { func toPgText(s *string) pgtype.Text {
if s == nil { if s == nil {
return pgtype.Text{Valid: false} return pgtype.Text{Valid: false}

View file

@ -79,6 +79,14 @@ type CadastroCliente struct {
Telefone pgtype.Text `json:"telefone"` Telefone pgtype.Text `json:"telefone"`
CriadoEm pgtype.Timestamptz `json:"criado_em"` CriadoEm pgtype.Timestamptz `json:"criado_em"`
AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"` AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"`
CpfCnpj pgtype.Text `json:"cpf_cnpj"`
Cep pgtype.Text `json:"cep"`
Endereco pgtype.Text `json:"endereco"`
Numero pgtype.Text `json:"numero"`
Complemento pgtype.Text `json:"complemento"`
Bairro pgtype.Text `json:"bairro"`
Cidade pgtype.Text `json:"cidade"`
Estado pgtype.Text `json:"estado"`
} }
type CadastroFot struct { type CadastroFot struct {

View file

@ -12,16 +12,37 @@ import (
) )
const createCadastroCliente = `-- name: CreateCadastroCliente :one const createCadastroCliente = `-- name: CreateCadastroCliente :one
INSERT INTO cadastro_clientes (usuario_id, empresa_id, nome, telefone) INSERT INTO cadastro_clientes (
VALUES ($1, $2, $3, $4) usuario_id,
RETURNING id, usuario_id, empresa_id, nome, telefone, criado_em, atualizado_em empresa_id,
nome,
telefone,
cpf_cnpj,
cep,
endereco,
numero,
complemento,
bairro,
cidade,
estado
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING id, usuario_id, empresa_id, nome, telefone, criado_em, atualizado_em, cpf_cnpj, cep, endereco, numero, complemento, bairro, cidade, estado
` `
type CreateCadastroClienteParams struct { type CreateCadastroClienteParams struct {
UsuarioID pgtype.UUID `json:"usuario_id"` UsuarioID pgtype.UUID `json:"usuario_id"`
EmpresaID pgtype.UUID `json:"empresa_id"` EmpresaID pgtype.UUID `json:"empresa_id"`
Nome pgtype.Text `json:"nome"` Nome pgtype.Text `json:"nome"`
Telefone pgtype.Text `json:"telefone"` Telefone pgtype.Text `json:"telefone"`
CpfCnpj pgtype.Text `json:"cpf_cnpj"`
Cep pgtype.Text `json:"cep"`
Endereco pgtype.Text `json:"endereco"`
Numero pgtype.Text `json:"numero"`
Complemento pgtype.Text `json:"complemento"`
Bairro pgtype.Text `json:"bairro"`
Cidade pgtype.Text `json:"cidade"`
Estado pgtype.Text `json:"estado"`
} }
func (q *Queries) CreateCadastroCliente(ctx context.Context, arg CreateCadastroClienteParams) (CadastroCliente, error) { func (q *Queries) CreateCadastroCliente(ctx context.Context, arg CreateCadastroClienteParams) (CadastroCliente, error) {
@ -30,6 +51,14 @@ func (q *Queries) CreateCadastroCliente(ctx context.Context, arg CreateCadastroC
arg.EmpresaID, arg.EmpresaID,
arg.Nome, arg.Nome,
arg.Telefone, arg.Telefone,
arg.CpfCnpj,
arg.Cep,
arg.Endereco,
arg.Numero,
arg.Complemento,
arg.Bairro,
arg.Cidade,
arg.Estado,
) )
var i CadastroCliente var i CadastroCliente
err := row.Scan( err := row.Scan(
@ -40,6 +69,14 @@ func (q *Queries) CreateCadastroCliente(ctx context.Context, arg CreateCadastroC
&i.Telefone, &i.Telefone,
&i.CriadoEm, &i.CriadoEm,
&i.AtualizadoEm, &i.AtualizadoEm,
&i.CpfCnpj,
&i.Cep,
&i.Endereco,
&i.Numero,
&i.Complemento,
&i.Bairro,
&i.Cidade,
&i.Estado,
) )
return i, err return i, err
} }
@ -91,6 +128,34 @@ func (q *Queries) DeleteUsuario(ctx context.Context, id pgtype.UUID) error {
return err return err
} }
const getCadastroClienteByUsuarioID = `-- name: GetCadastroClienteByUsuarioID :one
SELECT id, usuario_id, empresa_id, nome, telefone, criado_em, atualizado_em, cpf_cnpj, cep, endereco, numero, complemento, bairro, cidade, estado FROM cadastro_clientes
WHERE usuario_id = $1 LIMIT 1
`
func (q *Queries) GetCadastroClienteByUsuarioID(ctx context.Context, usuarioID pgtype.UUID) (CadastroCliente, error) {
row := q.db.QueryRow(ctx, getCadastroClienteByUsuarioID, usuarioID)
var i CadastroCliente
err := row.Scan(
&i.ID,
&i.UsuarioID,
&i.EmpresaID,
&i.Nome,
&i.Telefone,
&i.CriadoEm,
&i.AtualizadoEm,
&i.CpfCnpj,
&i.Cep,
&i.Endereco,
&i.Numero,
&i.Complemento,
&i.Bairro,
&i.Cidade,
&i.Estado,
)
return i, err
}
const getUsuarioByEmail = `-- name: GetUsuarioByEmail :one const getUsuarioByEmail = `-- name: GetUsuarioByEmail :one
SELECT u.id, u.email, u.senha_hash, u.role, u.tipo_profissional, u.ativo, u.criado_em, u.atualizado_em, u.regioes_permitidas, SELECT u.id, u.email, u.senha_hash, u.role, u.tipo_profissional, u.ativo, u.criado_em, u.atualizado_em, u.regioes_permitidas,
COALESCE(cp.nome, cc.nome, '') as nome, COALESCE(cp.nome, cc.nome, '') as nome,
@ -201,6 +266,7 @@ FROM usuarios u
LEFT JOIN cadastro_profissionais cp ON u.id = cp.usuario_id LEFT JOIN cadastro_profissionais cp ON u.id = cp.usuario_id
LEFT JOIN cadastro_clientes cc ON u.id = cc.usuario_id LEFT JOIN cadastro_clientes cc ON u.id = cc.usuario_id
LEFT JOIN empresas e ON cc.empresa_id = e.id LEFT JOIN empresas e ON cc.empresa_id = e.id
WHERE $1::text = ANY(u.regioes_permitidas)
ORDER BY u.criado_em DESC ORDER BY u.criado_em DESC
` `
@ -219,8 +285,8 @@ type ListAllUsuariosRow struct {
EmpresaNome pgtype.Text `json:"empresa_nome"` EmpresaNome pgtype.Text `json:"empresa_nome"`
} }
func (q *Queries) ListAllUsuarios(ctx context.Context) ([]ListAllUsuariosRow, error) { func (q *Queries) ListAllUsuarios(ctx context.Context, dollar_1 string) ([]ListAllUsuariosRow, error) {
rows, err := q.db.Query(ctx, listAllUsuarios) rows, err := q.db.Query(ctx, listAllUsuarios, dollar_1)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -313,6 +379,73 @@ func (q *Queries) ListUsuariosPending(ctx context.Context, dollar_1 string) ([]L
return items, nil return items, nil
} }
const updateCadastroCliente = `-- name: UpdateCadastroCliente :one
UPDATE cadastro_clientes
SET
nome = COALESCE($2, nome),
telefone = COALESCE($3, telefone),
cpf_cnpj = COALESCE($4, cpf_cnpj),
cep = COALESCE($5, cep),
endereco = COALESCE($6, endereco),
numero = COALESCE($7, numero),
complemento = COALESCE($8, complemento),
bairro = COALESCE($9, bairro),
cidade = COALESCE($10, cidade),
estado = COALESCE($11, estado),
atualizado_em = NOW()
WHERE usuario_id = $1
RETURNING id, usuario_id, empresa_id, nome, telefone, criado_em, atualizado_em, cpf_cnpj, cep, endereco, numero, complemento, bairro, cidade, estado
`
type UpdateCadastroClienteParams struct {
UsuarioID pgtype.UUID `json:"usuario_id"`
Nome pgtype.Text `json:"nome"`
Telefone pgtype.Text `json:"telefone"`
CpfCnpj pgtype.Text `json:"cpf_cnpj"`
Cep pgtype.Text `json:"cep"`
Endereco pgtype.Text `json:"endereco"`
Numero pgtype.Text `json:"numero"`
Complemento pgtype.Text `json:"complemento"`
Bairro pgtype.Text `json:"bairro"`
Cidade pgtype.Text `json:"cidade"`
Estado pgtype.Text `json:"estado"`
}
func (q *Queries) UpdateCadastroCliente(ctx context.Context, arg UpdateCadastroClienteParams) (CadastroCliente, error) {
row := q.db.QueryRow(ctx, updateCadastroCliente,
arg.UsuarioID,
arg.Nome,
arg.Telefone,
arg.CpfCnpj,
arg.Cep,
arg.Endereco,
arg.Numero,
arg.Complemento,
arg.Bairro,
arg.Cidade,
arg.Estado,
)
var i CadastroCliente
err := row.Scan(
&i.ID,
&i.UsuarioID,
&i.EmpresaID,
&i.Nome,
&i.Telefone,
&i.CriadoEm,
&i.AtualizadoEm,
&i.CpfCnpj,
&i.Cep,
&i.Endereco,
&i.Numero,
&i.Complemento,
&i.Bairro,
&i.Cidade,
&i.Estado,
)
return i, err
}
const updateUsuarioAtivo = `-- name: UpdateUsuarioAtivo :one const updateUsuarioAtivo = `-- name: UpdateUsuarioAtivo :one
UPDATE usuarios UPDATE usuarios
SET ativo = $2, atualizado_em = NOW() SET ativo = $2, atualizado_em = NOW()

View file

@ -0,0 +1,9 @@
ALTER TABLE cadastro_clientes
ADD COLUMN IF NOT EXISTS cpf_cnpj VARCHAR(20),
ADD COLUMN IF NOT EXISTS cep VARCHAR(10),
ADD COLUMN IF NOT EXISTS endereco VARCHAR(255),
ADD COLUMN IF NOT EXISTS numero VARCHAR(20),
ADD COLUMN IF NOT EXISTS complemento VARCHAR(100),
ADD COLUMN IF NOT EXISTS bairro VARCHAR(100),
ADD COLUMN IF NOT EXISTS cidade VARCHAR(100),
ADD COLUMN IF NOT EXISTS estado CHAR(2);

View file

@ -77,9 +77,44 @@ FROM usuarios u
LEFT JOIN cadastro_profissionais cp ON u.id = cp.usuario_id LEFT JOIN cadastro_profissionais cp ON u.id = cp.usuario_id
LEFT JOIN cadastro_clientes cc ON u.id = cc.usuario_id LEFT JOIN cadastro_clientes cc ON u.id = cc.usuario_id
LEFT JOIN empresas e ON cc.empresa_id = e.id LEFT JOIN empresas e ON cc.empresa_id = e.id
WHERE $1::text = ANY(u.regioes_permitidas)
ORDER BY u.criado_em DESC; ORDER BY u.criado_em DESC;
-- name: CreateCadastroCliente :one -- name: CreateCadastroCliente :one
INSERT INTO cadastro_clientes (usuario_id, empresa_id, nome, telefone) INSERT INTO cadastro_clientes (
VALUES ($1, $2, $3, $4) usuario_id,
empresa_id,
nome,
telefone,
cpf_cnpj,
cep,
endereco,
numero,
complemento,
bairro,
cidade,
estado
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING *; RETURNING *;
-- name: UpdateCadastroCliente :one
UPDATE cadastro_clientes
SET
nome = COALESCE($2, nome),
telefone = COALESCE($3, telefone),
cpf_cnpj = COALESCE($4, cpf_cnpj),
cep = COALESCE($5, cep),
endereco = COALESCE($6, endereco),
numero = COALESCE($7, numero),
complemento = COALESCE($8, complemento),
bairro = COALESCE($9, bairro),
cidade = COALESCE($10, cidade),
estado = COALESCE($11, estado),
atualizado_em = NOW()
WHERE usuario_id = $1
RETURNING *;
-- name: GetCadastroClienteByUsuarioID :one
SELECT * FROM cadastro_clientes
WHERE usuario_id = $1 LIMIT 1;

View file

@ -201,6 +201,16 @@ CREATE TABLE IF NOT EXISTS cadastro_clientes (
atualizado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(), atualizado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(usuario_id) UNIQUE(usuario_id)
); );
-- Migrations (Appended for Idempotency)
ALTER TABLE cadastro_clientes ADD COLUMN IF NOT EXISTS cpf_cnpj VARCHAR(20);
ALTER TABLE cadastro_clientes ADD COLUMN IF NOT EXISTS cep VARCHAR(10);
ALTER TABLE cadastro_clientes ADD COLUMN IF NOT EXISTS endereco VARCHAR(255);
ALTER TABLE cadastro_clientes ADD COLUMN IF NOT EXISTS numero VARCHAR(20);
ALTER TABLE cadastro_clientes ADD COLUMN IF NOT EXISTS complemento VARCHAR(100);
ALTER TABLE cadastro_clientes ADD COLUMN IF NOT EXISTS bairro VARCHAR(100);
ALTER TABLE cadastro_clientes ADD COLUMN IF NOT EXISTS cidade VARCHAR(100);
ALTER TABLE cadastro_clientes ADD COLUMN IF NOT EXISTS estado CHAR(2);
-- Agenda Table -- Agenda Table
CREATE TABLE IF NOT EXISTS agenda ( CREATE TABLE IF NOT EXISTS agenda (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),

View file

@ -102,12 +102,23 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
id: backendUser.id, id: backendUser.id,
email: backendUser.email, email: backendUser.email,
name: data.profissional?.nome || data.empresa?.nome || backendUser.name || backendUser.nome || backendUser.email.split('@')[0], name: data.profissional?.nome || data.empresa?.nome || backendUser.name || backendUser.nome || backendUser.email.split('@')[0],
phone: backendUser.phone || backendUser.telefone || backendUser.whatsapp, // Map phone
role: backendUser.role as UserRole, role: backendUser.role as UserRole,
ativo: backendUser.ativo, ativo: backendUser.ativo,
empresaId: backendUser.company_id || backendUser.empresa_id || backendUser.companyId, empresaId: backendUser.company_id || backendUser.empresa_id || backendUser.companyId,
companyName: backendUser.company_name || backendUser.empresa_nome || backendUser.companyName, companyName: backendUser.company_name || backendUser.empresa_nome || backendUser.companyName,
avatar: data.profissional?.avatar_url || data.empresa?.avatar_url || backendUser.avatar, avatar: data.profissional?.avatar_url || data.empresa?.avatar_url || backendUser.avatar,
allowedRegions: backendUser.allowed_regions || [], // Map allowed regions allowedRegions: backendUser.allowed_regions || [], // Map allowed regions
// Client specific fields
cpf_cnpj: backendUser.cpf_cnpj,
cep: backendUser.cep,
endereco: backendUser.endereco,
numero: backendUser.numero,
complemento: backendUser.complemento,
bairro: backendUser.bairro,
cidade: backendUser.cidade,
estado: backendUser.estado,
}; };
console.log("AuthContext: restoreSession mapped user:", mappedUser); console.log("AuthContext: restoreSession mapped user:", mappedUser);
if (!backendUser.ativo) { if (!backendUser.ativo) {
@ -191,12 +202,23 @@ const login = async (email: string, password?: string) => {
id: backendUser.id, id: backendUser.id,
email: backendUser.email, email: backendUser.email,
name: data.profissional?.nome || data.empresa?.nome || backendUser.name || backendUser.nome || backendUser.email.split('@')[0], name: data.profissional?.nome || data.empresa?.nome || backendUser.name || backendUser.nome || backendUser.email.split('@')[0],
phone: backendUser.phone || backendUser.telefone || backendUser.whatsapp, // Map phone
role: backendUser.role as UserRole, role: backendUser.role as UserRole,
ativo: backendUser.ativo, ativo: backendUser.ativo,
empresaId: backendUser.company_id || backendUser.empresa_id || backendUser.companyId, empresaId: backendUser.company_id || backendUser.empresa_id || backendUser.companyId,
companyName: backendUser.company_name || backendUser.empresa_nome || backendUser.companyName, companyName: backendUser.company_name || backendUser.empresa_nome || backendUser.companyName,
avatar: data.profissional?.avatar_url || data.empresa?.avatar_url || backendUser.avatar, avatar: data.profissional?.avatar_url || data.empresa?.avatar_url || backendUser.avatar,
allowedRegions: backendUser.allowed_regions || [], allowedRegions: backendUser.allowed_regions || [],
// Client specific fields
cpf_cnpj: backendUser.cpf_cnpj,
cep: backendUser.cep,
endereco: backendUser.endereco,
numero: backendUser.numero,
complemento: backendUser.complemento,
bairro: backendUser.bairro,
cidade: backendUser.cidade,
estado: backendUser.estado,
}; };
setUser(mappedUser); setUser(mappedUser);
@ -240,21 +262,38 @@ const login = async (email: string, password?: string) => {
} }
}; };
const register = async (data: { nome: string; email: string; senha: string; telefone: string; role: string; empresaId?: string; tipo_profissional?: string; regiao?: string }) => { const register = async (data: any) => {
setIsLoading(true);
try { try {
// Destructure to separate empresaId from the rest const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8080";
const { empresaId, ...rest } = data;
const payload = { const payload = {
...rest, email: data.email,
empresa_id: empresaId senha: data.password || data.senha,
role: data.role,
nome: data.name || data.nome,
telefone: data.phone || data.telefone,
professional_type: data.professional_type || data.tipo_profissional,
empresa_id: data.company_id || data.empresaId || data.empresa_id,
regiao: data.regiao,
cpf_cnpj: data.cpf_cnpj,
cep: data.cep,
endereco: data.endereco,
numero: data.numero,
complemento: data.complemento,
bairro: data.bairro,
cidade: data.cidade,
estado: data.estado
}; };
const response = await fetch(`${import.meta.env.VITE_API_URL}/auth/register`, {
method: 'POST', const response = await fetch(`${API_URL}/auth/register`, {
headers: { method: "POST",
'Content-Type': 'application/json', headers: {
'x-regiao': data.regiao || 'SP' // Pass region header "Content-Type": "application/json",
"x-regiao": data.regiao || 'SP'
}, },
body: JSON.stringify(payload) body: JSON.stringify(payload),
}); });
if (!response.ok) { if (!response.ok) {
@ -265,9 +304,6 @@ const login = async (email: string, password?: string) => {
const responseData = await response.json(); const responseData = await response.json();
// IF user is returned (auto-login), logic:
// Only set user/token if they are ACTIVE (which they won't be for standard clients/professionals)
// This allows the "Pending Approval" modal to show instead of auto-redirecting.
if (responseData.user && responseData.user.ativo) { if (responseData.user && responseData.user.ativo) {
if (responseData.access_token) { if (responseData.access_token) {
localStorage.setItem('token', responseData.access_token); localStorage.setItem('token', responseData.access_token);
@ -279,14 +315,26 @@ const login = async (email: string, password?: string) => {
id: backendUser.id, id: backendUser.id,
email: backendUser.email, email: backendUser.email,
name: backendUser.nome || backendUser.email.split('@')[0], name: backendUser.nome || backendUser.email.split('@')[0],
phone: backendUser.phone || backendUser.telefone || backendUser.whatsapp, // Map phone
role: backendUser.role as UserRole, role: backendUser.role as UserRole,
ativo: backendUser.ativo, ativo: backendUser.ativo,
empresaId: backendUser.company_id || backendUser.empresa_id || backendUser.companyId, empresaId: backendUser.company_id || backendUser.empresa_id || backendUser.companyId,
companyName: backendUser.company_name || backendUser.empresa_nome || backendUser.companyName, companyName: backendUser.company_name || backendUser.empresa_nome || backendUser.companyName,
avatar: backendUser.avatar,
allowedRegions: backendUser.allowed_regions || [],
// Client specific fields
cpf_cnpj: backendUser.cpf_cnpj,
cep: backendUser.cep,
endereco: backendUser.endereco,
numero: backendUser.numero,
complemento: backendUser.complemento,
bairro: backendUser.bairro,
cidade: backendUser.cidade,
estado: backendUser.estado,
}; };
setUser(mappedUser); setUser(mappedUser);
} }
// If user is NOT active, we do NOT set the token/user state, preventing auto-login.
return { return {
success: true, success: true,
@ -295,7 +343,12 @@ const login = async (email: string, password?: string) => {
}; };
} catch (err) { } catch (err) {
console.error('Registration error:', err); console.error('Registration error:', err);
throw err; if (err instanceof Error) {
return { success: false, error: err.message };
}
return { success: false, error: "Erro desconhecido" };
} finally {
setIsLoading(false);
} }
}; };

View file

@ -4,6 +4,7 @@ import {
ProfessionalData, ProfessionalData,
} from "../components/ProfessionalForm"; } from "../components/ProfessionalForm";
import { useAuth } from "../contexts/AuthContext"; import { useAuth } from "../contexts/AuthContext";
import { toast } from "react-hot-toast";
interface ProfessionalRegisterProps { interface ProfessionalRegisterProps {
onNavigate: (page: string) => void; onNavigate: (page: string) => void;
@ -59,7 +60,12 @@ export const ProfessionalRegister: React.FC<ProfessionalRegisterProps> = ({
console.log("Upload concluído. URL:", avatarUrl); console.log("Upload concluído. URL:", avatarUrl);
} catch (err) { } catch (err) {
console.error("Erro no upload do avatar:", err); console.error("Erro no upload do avatar:", err);
throw new Error("Falha ao enviar foto de perfil: " + (err instanceof Error ? err.message : "Erro desconhecido")); // Não trava o cadastro, apenas avisa
toast.error("Não foi possível enviar a foto de perfil. O cadastro seguirá sem foto.", {
duration: 5000,
icon: '⚠️'
});
// Mantém avatarUrl vazio e prossegue
} }
// Mapear dados do formulário para o payload esperado pelo backend // Mapear dados do formulário para o payload esperado pelo backend

View file

@ -6,7 +6,7 @@ import {
import { Navbar } from "../components/Navbar"; import { Navbar } from "../components/Navbar";
import { Button } from "../components/Button"; import { Button } from "../components/Button";
import { useAuth } from "../contexts/AuthContext"; import { useAuth } from "../contexts/AuthContext";
import { getFunctions, createProfessional, updateProfessional } from "../services/apiService"; import { getFunctions, createProfessional, updateProfessional, updateUserProfile } from "../services/apiService";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { formatCPFCNPJ, formatPhone } from "../utils/masks"; import { formatCPFCNPJ, formatPhone } from "../utils/masks";
@ -127,6 +127,27 @@ export const ProfilePage: React.FC = () => {
const funcsRes = await getFunctions(); const funcsRes = await getFunctions();
if (funcsRes.data) setFunctions(funcsRes.data); if (funcsRes.data) setFunctions(funcsRes.data);
if (user?.role === "EVENT_OWNER") {
// Clients don't have professional profile. Populate from User.
// Ensure user object has these fields (mapped in AuthContext)
setFormData({
...formData,
nome: user.name || "",
email: user.email || "",
whatsapp: user.phone || "",
cpf_cnpj_titular: user.cpf_cnpj || "",
cep: user.cep || "",
endereco: user.endereco || "",
numero: user.numero || "",
complemento: user.complemento || "",
bairro: user.bairro || "",
cidade: user.cidade || "",
uf: user.estado || "",
});
setIsLoading(false);
return;
}
// Try to fetch existing profile // Try to fetch existing profile
const response = await fetch(`${import.meta.env.VITE_API_URL || "http://localhost:8080"}/api/profissionais/me`, { const response = await fetch(`${import.meta.env.VITE_API_URL || "http://localhost:8080"}/api/profissionais/me`, {
headers: { Authorization: `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` }
@ -270,6 +291,26 @@ export const ProfilePage: React.FC = () => {
setIsSaving(true); setIsSaving(true);
try { try {
if (!token) throw new Error("Usuário não autenticado"); if (!token) throw new Error("Usuário não autenticado");
if (user?.role === "EVENT_OWNER") {
const clientPayload = {
name: formData.nome,
phone: formData.whatsapp,
cpf_cnpj: formData.cpf_cnpj_titular,
cep: formData.cep,
endereco: formData.endereco,
numero: formData.numero,
complemento: formData.complemento,
bairro: formData.bairro,
cidade: formData.cidade,
estado: formData.uf
};
const res = await updateUserProfile(clientPayload, token);
if (res.error) throw new Error(res.error);
toast.success("Perfil atualizado com sucesso!");
setIsSaving(false);
return;
}
// Payload preparation // Payload preparation
// For create/update, we need `funcao_profissional_id` (single) for backward compatibility optionally // For create/update, we need `funcao_profissional_id` (single) for backward compatibility optionally

View file

@ -3,6 +3,7 @@ import { Button } from "../components/Button";
import { Input } from "../components/Input"; import { Input } from "../components/Input";
import { useAuth } from "../contexts/AuthContext"; import { useAuth } from "../contexts/AuthContext";
import { getCompanies } from "../services/apiService"; import { getCompanies } from "../services/apiService";
import { formatPhone, formatCPFCNPJ, formatCEP } from "../utils/masks";
interface RegisterProps { interface RegisterProps {
onNavigate: (page: string) => void; onNavigate: (page: string) => void;
@ -21,6 +22,14 @@ export const Register: React.FC<RegisterProps> = ({ onNavigate }) => {
password: "", password: "",
confirmPassword: "", confirmPassword: "",
empresaId: "", empresaId: "",
cpfCnpj: "",
cep: "",
endereco: "",
numero: "",
complemento: "",
bairro: "",
cidade: "",
estado: "",
}); });
const [agreedToTerms, setAgreedToTerms] = useState(false); const [agreedToTerms, setAgreedToTerms] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@ -84,12 +93,20 @@ export const Register: React.FC<RegisterProps> = ({ onNavigate }) => {
try { try {
await register({ await register({
nome: formData.name, name: formData.name,
email: formData.email, email: formData.email,
senha: formData.password, password: formData.password,
telefone: formData.phone, phone: formData.phone,
role: "EVENT_OWNER", // Client Role role: "EVENT_OWNER", // Client Role
empresaId: formData.empresaId, company_id: formData.empresaId,
cpf_cnpj: formData.cpfCnpj,
cep: formData.cep,
endereco: formData.endereco,
numero: formData.numero,
complemento: formData.complemento,
bairro: formData.bairro,
cidade: formData.cidade,
estado: formData.estado,
}); });
// Limpar dados de sessão após cadastro bem-sucedido // Limpar dados de sessão após cadastro bem-sucedido
sessionStorage.removeItem('accessCodeValidated'); sessionStorage.removeItem('accessCodeValidated');
@ -233,9 +250,82 @@ export const Register: React.FC<RegisterProps> = ({ onNavigate }) => {
required required
placeholder="(00) 00000-0000" placeholder="(00) 00000-0000"
value={formData.phone} value={formData.phone}
onChange={(e) => handleChange("phone", e.target.value)} onChange={(e) => handleChange("phone", formatPhone(e.target.value))}
mask="phone" mask="phone"
/> />
<Input
label="CPF/CNPJ"
value={formData.cpfCnpj}
onChange={(e) => handleChange("cpfCnpj", formatCPFCNPJ(e.target.value))}
placeholder="000.000.000-00"
maxLength={18}
/>
<div className="grid grid-cols-2 gap-3">
<Input
label="CEP"
value={formData.cep}
onChange={(e) => handleChange("cep", formatCEP(e.target.value))}
placeholder="00000-000"
onBlur={(e) => {
const cep = e.target.value.replace(/\D/g, '');
if (cep.length === 8) {
fetch(`https://viacep.com.br/ws/${cep}/json/`)
.then(res => res.json())
.then(data => {
if (!data.erro) {
setFormData(prev => ({
...prev,
endereco: data.logradouro,
bairro: data.bairro,
cidade: data.localidade,
estado: data.uf
}));
}
});
}
}}
/>
<Input
label="Número"
value={formData.numero}
onChange={(e) => handleChange("numero", e.target.value)}
/>
</div>
<Input
label="Endereço"
value={formData.endereco}
onChange={(e) => handleChange("endereco", e.target.value)}
/>
<div className="grid grid-cols-2 gap-3">
<Input
label="Complemento"
value={formData.complemento}
onChange={(e) => handleChange("complemento", e.target.value)}
/>
<Input
label="Bairro"
value={formData.bairro}
onChange={(e) => handleChange("bairro", e.target.value)}
/>
</div>
<div className="grid grid-cols-[2fr_1fr] gap-3">
<Input
label="Cidade"
value={formData.cidade}
onChange={(e) => handleChange("cidade", e.target.value)}
/>
<Input
label="UF"
value={formData.estado}
onChange={(e) => handleChange("estado", e.target.value)}
maxLength={2}
/>
</div>
<div> <div>
<label className="block text-[10px] sm:text-xs md:text-sm font-medium text-gray-700 mb-1"> <label className="block text-[10px] sm:text-xs md:text-sm font-medium text-gray-700 mb-1">

View file

@ -7,6 +7,7 @@ import {
approveUser as apiApproveUser, approveUser as apiApproveUser,
rejectUser as apiRejectUser, rejectUser as apiRejectUser,
updateUserRole, updateUserRole,
getCompanies,
} from "../services/apiService"; } from "../services/apiService";
import { UserApprovalStatus, UserRole } from "../types"; import { UserApprovalStatus, UserRole } from "../types";
import { import {
@ -21,6 +22,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { Button } from "../components/Button"; import { Button } from "../components/Button";
import { Input } from "../components/Input"; import { Input } from "../components/Input";
import { formatPhone, formatCPFCNPJ, formatCEP } from "../utils/masks";
// INTERFACES // INTERFACES
interface UserApprovalProps { interface UserApprovalProps {
@ -194,6 +196,16 @@ const CreateUserModal: React.FC<CreateUserModalProps> = ({
formData, formData,
setFormData setFormData
}) => { }) => {
// Fetch companies
const [companies, setCompanies] = useState<any[]>([]);
useEffect(() => {
if (formData.role === "EVENT_OWNER") {
getCompanies().then(res => {
if(res.data) setCompanies(res.data);
});
}
}, [formData.role]);
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4 fade-in"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4 fade-in">
@ -226,7 +238,7 @@ const CreateUserModal: React.FC<CreateUserModalProps> = ({
<Input <Input
label="Telefone (Whatsapp)" label="Telefone (Whatsapp)"
value={formData.telefone} value={formData.telefone}
onChange={(e) => setFormData({...formData, telefone: e.target.value})} onChange={(e) => setFormData({...formData, telefone: formatPhone(e.target.value)})}
/> />
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<Input <Input
@ -237,39 +249,135 @@ const CreateUserModal: React.FC<CreateUserModalProps> = ({
value={formData.senha} value={formData.senha}
onChange={(e) => setFormData({...formData, senha: e.target.value})} onChange={(e) => setFormData({...formData, senha: e.target.value})}
/> />
<div> </div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <div>
Função <label className="block text-sm font-medium text-gray-700 mb-1">
</label> Tipo de Usuário
<select </label>
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#B9CF33] focus:border-transparent" <select
value={formData.role} value={formData.role}
onChange={(e) => setFormData({...formData, role: e.target.value as any})} onChange={(e) => setFormData({...formData, role: e.target.value})}
> className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-photum-green focus:border-transparent outline-none transition-all"
<option value="PHOTOGRAPHER">Profissional</option> >
<option value="RESEARCHER">Pesquisador</option> <option value="PHOTOGRAPHER">Fotógrafo</option>
<option value="BUSINESS_OWNER">Dono do Negócio</option> <option value="EVENT_OWNER">Cliente (Empresa)</option>
<option value="SUPERADMIN">Super Admin</option> <option value="BUSINESS_OWNER">Dono de Negócio</option>
<option value="EVENT_OWNER">Cliente (Empresa)</option> <option value="ADMIN">Administrador</option>
</select> <option value="AGENDA_VIEWER">Visualizador de Agenda</option>
</div> <option value="RESEARCHER">Pesquisador</option>
</select>
</div> </div>
{formData.role === "EVENT_OWNER" && (
<div className="space-y-4 border-t pt-4 mt-2">
<h4 className="font-medium text-gray-900">Dados do Cliente</h4>
<div className="grid grid-cols-2 gap-4">
<Input
label="CPF/CNPJ"
value={formData.cpfCnpj || ''}
onChange={(e) => setFormData({...formData, cpfCnpj: formatCPFCNPJ(e.target.value)})}
placeholder="000.000.000-00"
maxLength={18}
/>
<Input
label="CEP"
value={formData.cep || ''}
onChange={(e) => setFormData({...formData, cep: formatCEP(e.target.value)})}
placeholder="00000-000"
onBlur={(e) => {
const cep = e.target.value.replace(/\D/g, '');
if (cep.length === 8) {
fetch(`https://viacep.com.br/ws/${cep}/json/`)
.then(res => res.json())
.then(data => {
if (!data.erro) {
setFormData(prev => ({
...prev,
endereco: data.logradouro,
bairro: data.bairro,
cidade: data.localidade,
estado: data.uf
}));
}
});
}
}}
/>
</div>
<div className="grid grid-cols-[2fr_1fr] gap-4">
<Input
label="Endereço"
value={formData.endereco || ''}
onChange={(e) => setFormData({...formData, endereco: e.target.value})}
/>
<Input
label="Número"
value={formData.numero || ''}
onChange={(e) => setFormData({...formData, numero: e.target.value})}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Input
label="Complemento"
value={formData.complemento || ''}
onChange={(e) => setFormData({...formData, complemento: e.target.value})}
/>
<Input
label="Bairro"
value={formData.bairro || ''}
onChange={(e) => setFormData({...formData, bairro: e.target.value})}
/>
</div>
<div className="grid grid-cols-[2fr_1fr] gap-4">
<Input
label="Cidade"
value={formData.cidade || ''}
onChange={(e) => setFormData({...formData, cidade: e.target.value})}
/>
<Input
label="UF"
value={formData.estado || ''}
onChange={(e) => setFormData({...formData, estado: e.target.value})}
maxLength={2}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Empresa *
</label>
<select
required
value={formData.empresa_id || ''}
onChange={(e) => setFormData({...formData, empresa_id: e.target.value})}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-photum-green focus:border-transparent outline-none transition-all"
>
<option value="">Selecione uma empresa</option>
{companies.map(c => (
<option key={c.id} value={c.id}>{c.nome}</option>
))}
</select>
</div>
</div>
)}
{formData.role === "PHOTOGRAPHER" && ( {formData.role === "PHOTOGRAPHER" && (
<div> <div className="space-y-4">
<label className="block text-sm font-medium text-gray-700 mb-1"> <div>
Tipo de Profissional <label className="block text-sm font-medium text-gray-700 mb-1">
</label> Tipo de Profissional
<select </label>
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#B9CF33] focus:border-transparent" <select
value={formData.professional_type} value={formData.professional_type}
onChange={(e) => setFormData({...formData, professional_type: e.target.value})} onChange={(e) => setFormData({...formData, professional_type: e.target.value})}
> className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-photum-green focus:border-transparent outline-none transition-all"
<option value="">Selecione...</option> >
<option value="Fotógrafo">Fotógrafo</option> <option value="">Selecione...</option>
<option value="Cinegrafista">Cinegrafista</option> <option value="FOTOGRAFO">Fotógrafo</option>
<option value="Recepcionista">Recepcionista</option> <option value="CINEGRAFISTA">Cinegrafista</option>
</select> <option value="EDITOR">Editor</option>
</select>
</div>
</div> </div>
)} )}
@ -316,6 +424,16 @@ export const UserApproval: React.FC<UserApprovalProps> = ({ onNavigate }) => {
role: "PHOTOGRAPHER", role: "PHOTOGRAPHER",
telefone: "", telefone: "",
professional_type: "", // For photographer subtype professional_type: "", // For photographer subtype
empresa_id: "",
cpfCnpj: "",
cep: "",
endereco: "",
numero: "",
complemento: "",
bairro: "",
cidade: "",
estado: "",
regiao: "",
}); });
const fetchUsers = async () => { const fetchUsers = async () => {
@ -393,7 +511,17 @@ export const UserApproval: React.FC<UserApprovalProps> = ({ onNavigate }) => {
senha: createFormData.senha, senha: createFormData.senha,
role: createFormData.role, role: createFormData.role,
telefone: createFormData.telefone, telefone: createFormData.telefone,
tipo_profissional: createFormData.role === "PHOTOGRAPHER" ? createFormData.professional_type : undefined professional_type: createFormData.role === "PHOTOGRAPHER" ? createFormData.professional_type : undefined,
empresa_id: createFormData.role === "EVENT_OWNER" ? createFormData.empresa_id : undefined,
cpf_cnpj: createFormData.role === "EVENT_OWNER" ? createFormData.cpfCnpj : undefined,
cep: createFormData.role === "EVENT_OWNER" ? createFormData.cep : undefined,
endereco: createFormData.role === "EVENT_OWNER" ? createFormData.endereco : undefined,
numero: createFormData.role === "EVENT_OWNER" ? createFormData.numero : undefined,
complemento: createFormData.role === "EVENT_OWNER" ? createFormData.complemento : undefined,
bairro: createFormData.role === "EVENT_OWNER" ? createFormData.bairro : undefined,
cidade: createFormData.role === "EVENT_OWNER" ? createFormData.cidade : undefined,
estado: createFormData.role === "EVENT_OWNER" ? createFormData.estado : undefined,
regiao: createFormData.regiao || undefined
}; };
const result = await createAdminUser(payload, token); const result = await createAdminUser(payload, token);
@ -403,7 +531,8 @@ export const UserApproval: React.FC<UserApprovalProps> = ({ onNavigate }) => {
alert("Usuário criado com sucesso!"); alert("Usuário criado com sucesso!");
setShowCreateModal(false); setShowCreateModal(false);
setCreateFormData({ setCreateFormData({
nome: "", email: "", senha: "", role: "PHOTOGRAPHER", telefone: "", professional_type: "" nome: "", email: "", senha: "", role: "PHOTOGRAPHER", telefone: "", professional_type: "", empresa_id: "",
cpfCnpj: "", cep: "", endereco: "", numero: "", complemento: "", bairro: "", cidade: "", estado: "", regiao: ""
}); });
fetchUsers(); fetchUsers();
} }

View file

@ -159,6 +159,42 @@ export async function updateProfessional(id: string, data: any, token: string):
} }
} }
/**
* Atualiza o perfil do usuário logado (Cliente/Evento Owner)
*/
export async function updateUserProfile(data: any, token: string): Promise<ApiResponse<any>> {
try {
const response = await fetch(`${API_BASE_URL}/api/me`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
"x-regiao": localStorage.getItem("photum_selected_region") || "SP"
},
body: JSON.stringify(data),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
}
const responseData = await response.json();
return {
data: responseData,
error: null,
isBackendDown: false,
};
} catch (error) {
console.error("Error updating user profile:", error);
return {
data: null,
error: error instanceof Error ? error.message : "Erro desconhecido",
isBackendDown: true,
};
}
}
/** /**
* Remove um profissional * Remove um profissional
*/ */

View file

@ -48,7 +48,18 @@ export interface User {
ativo?: boolean; ativo?: boolean;
empresaId?: string; // ID da empresa vinculada (para Business Owners) empresaId?: string; // ID da empresa vinculada (para Business Owners)
companyName?: string; // Nome da empresa vinculada companyName?: string; // Nome da empresa vinculada
allowedRegions?: string[]; // Regiões permitidas allowedRegions?: string[]; // Regiões permitidas para o usuário
// Client / Event Owner specific fields
cpf_cnpj?: string;
cep?: string;
endereco?: string;
numero?: string;
complemento?: string;
bairro?: string;
cidade?: string;
estado?: string;
} }
export interface Institution { export interface Institution {

View file

@ -1,24 +1,26 @@
export const formatCPFCNPJ = (value: string) => { export const formatCPFCNPJ = (value: string) => {
const clean = value.replace(/\D/g, ""); const digits = value.replace(/\D/g, "");
if (digits.length <= 11) {
if (clean.length <= 11) { return digits
// CPF
return clean
.replace(/(\d{3})(\d)/, "$1.$2") .replace(/(\d{3})(\d)/, "$1.$2")
.replace(/(\d{3})(\d)/, "$1.$2") .replace(/(\d{3})(\d)/, "$1.$2")
.replace(/(\d{3})(\d{1,2})/, "$1-$2") .replace(/(\d{3})(\d{1,2})$/, "$1-$2");
.replace(/(-\d{2})\d+?$/, "$1");
} else { } else {
// CNPJ return digits
return clean
.replace(/^(\d{2})(\d)/, "$1.$2") .replace(/^(\d{2})(\d)/, "$1.$2")
.replace(/^(\d{2})\.(\d{3})(\d)/, "$1.$2.$3") .replace(/^(\d{2})\.(\d{3})(\d)/, "$1.$2.$3")
.replace(/\.(\d{3})(\d)/, ".$1/$2") .replace(/\.(\d{3})(\d)/, ".$1/$2")
.replace(/(\d{4})(\d)/, "$1-$2") .replace(/(\d{4})(\d)/, "$1-$2");
.replace(/(-\d{2})\d+?$/, "$1");
} }
}; };
export const formatCEP = (value: string) => {
return value
.replace(/\D/g, "")
.replace(/^(\d{5})(\d)/, "$1-$2")
.slice(0, 9);
};
export const formatPhone = (value: string) => { export const formatPhone = (value: string) => {
const clean = value.replace(/\D/g, ""); const clean = value.replace(/\D/g, "");