feat(profissionais): implementa importação via Excel e dashboard de equipe
Frontend: - Implementa leitura e processamento de arquivos Excel (.xlsx) para Profissionais. - Adiciona validação e truncamento automático de campos (CPF, UF, Whatsapp) para evitar erros. - Cria lógica de mapeamento automático de Funções (ex: Fotógrafo, Cinegrafista). - Adiciona card "Total Geral" na dashboard de Equipe (/equipe). Backend: - Cria endpoint e serviço de importação para cadastro em massa. - Implementa tratamento de erros robusto e prevenção de panics (nil pointers). - Ajusta queries de inserção e atualização (Upsert) no banco de dados. Geral: - Funcionalidade de importação estabilizada e validada. - Implementa fluxo de edicao inteligente e otimizacoes - Implementa deteccao de CPF existente no Admin (TeamPage) com redirecionamento automatico para Edicao. - Isola formulario em ProfessionalModal para performance. - Adiciona pre-checagem de CPF na API publica (retornando apenas dados seguros). - Otimiza renderizacao da lista de equipe.
This commit is contained in:
parent
a6ba63203a
commit
542c8d4388
13 changed files with 1690 additions and 831 deletions
|
|
@ -148,6 +148,7 @@ func main() {
|
|||
r.GET("/api/cursos", cursosHandler.List)
|
||||
r.GET("/api/empresas", empresasHandler.List)
|
||||
r.GET("/api/anos-formaturas", anosFormaturasHandler.List)
|
||||
r.GET("/api/profissionais/check", profissionaisHandler.CheckCPF) // Public Check
|
||||
r.GET("/api/tipos-servicos", tiposServicosHandler.List)
|
||||
r.GET("/api/tipos-eventos", tiposEventosHandler.List)
|
||||
r.GET("/api/tipos-eventos/:id/precos", tiposEventosHandler.ListPrices)
|
||||
|
|
@ -162,6 +163,7 @@ func main() {
|
|||
profGroup := api.Group("/profissionais")
|
||||
{
|
||||
profGroup.POST("", profissionaisHandler.Create)
|
||||
profGroup.POST("/import", profissionaisHandler.Import)
|
||||
profGroup.GET("", profissionaisHandler.List)
|
||||
profGroup.GET("/me", profissionaisHandler.Me)
|
||||
profGroup.GET("/:id", profissionaisHandler.Get)
|
||||
|
|
|
|||
|
|
@ -161,6 +161,90 @@ func (q *Queries) DeleteProfissional(ctx context.Context, id pgtype.UUID) error
|
|||
return err
|
||||
}
|
||||
|
||||
const getProfissionalByCPF = `-- name: GetProfissionalByCPF :one
|
||||
SELECT p.id, p.usuario_id, p.nome, p.funcao_profissional_id, p.endereco, p.cidade, p.uf, p.whatsapp, p.cpf_cnpj_titular, p.banco, p.agencia, p.conta_pix, p.carro_disponivel, p.tem_estudio, p.qtd_estudio, p.tipo_cartao, p.observacao, p.qual_tec, p.educacao_simpatia, p.desempenho_evento, p.disp_horario, p.media, p.tabela_free, p.extra_por_equipamento, p.equipamentos, p.email, p.avatar_url, p.criado_em, p.atualizado_em,
|
||||
COALESCE(
|
||||
(SELECT json_agg(json_build_object('id', f.id, 'nome', f.nome))
|
||||
FROM profissionais_funcoes_junction pfj
|
||||
JOIN funcoes_profissionais f ON pfj.funcao_id = f.id
|
||||
WHERE pfj.profissional_id = p.id
|
||||
), '[]'::json
|
||||
) as functions
|
||||
FROM cadastro_profissionais p
|
||||
WHERE p.cpf_cnpj_titular = $1 LIMIT 1
|
||||
`
|
||||
|
||||
type GetProfissionalByCPFRow struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
UsuarioID pgtype.UUID `json:"usuario_id"`
|
||||
Nome string `json:"nome"`
|
||||
FuncaoProfissionalID pgtype.UUID `json:"funcao_profissional_id"`
|
||||
Endereco pgtype.Text `json:"endereco"`
|
||||
Cidade pgtype.Text `json:"cidade"`
|
||||
Uf pgtype.Text `json:"uf"`
|
||||
Whatsapp pgtype.Text `json:"whatsapp"`
|
||||
CpfCnpjTitular pgtype.Text `json:"cpf_cnpj_titular"`
|
||||
Banco pgtype.Text `json:"banco"`
|
||||
Agencia pgtype.Text `json:"agencia"`
|
||||
ContaPix pgtype.Text `json:"conta_pix"`
|
||||
CarroDisponivel pgtype.Bool `json:"carro_disponivel"`
|
||||
TemEstudio pgtype.Bool `json:"tem_estudio"`
|
||||
QtdEstudio pgtype.Int4 `json:"qtd_estudio"`
|
||||
TipoCartao pgtype.Text `json:"tipo_cartao"`
|
||||
Observacao pgtype.Text `json:"observacao"`
|
||||
QualTec pgtype.Int4 `json:"qual_tec"`
|
||||
EducacaoSimpatia pgtype.Int4 `json:"educacao_simpatia"`
|
||||
DesempenhoEvento pgtype.Int4 `json:"desempenho_evento"`
|
||||
DispHorario pgtype.Int4 `json:"disp_horario"`
|
||||
Media pgtype.Numeric `json:"media"`
|
||||
TabelaFree pgtype.Text `json:"tabela_free"`
|
||||
ExtraPorEquipamento pgtype.Bool `json:"extra_por_equipamento"`
|
||||
Equipamentos pgtype.Text `json:"equipamentos"`
|
||||
Email pgtype.Text `json:"email"`
|
||||
AvatarUrl pgtype.Text `json:"avatar_url"`
|
||||
CriadoEm pgtype.Timestamptz `json:"criado_em"`
|
||||
AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"`
|
||||
Functions interface{} `json:"functions"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetProfissionalByCPF(ctx context.Context, cpfCnpjTitular pgtype.Text) (GetProfissionalByCPFRow, error) {
|
||||
row := q.db.QueryRow(ctx, getProfissionalByCPF, cpfCnpjTitular)
|
||||
var i GetProfissionalByCPFRow
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UsuarioID,
|
||||
&i.Nome,
|
||||
&i.FuncaoProfissionalID,
|
||||
&i.Endereco,
|
||||
&i.Cidade,
|
||||
&i.Uf,
|
||||
&i.Whatsapp,
|
||||
&i.CpfCnpjTitular,
|
||||
&i.Banco,
|
||||
&i.Agencia,
|
||||
&i.ContaPix,
|
||||
&i.CarroDisponivel,
|
||||
&i.TemEstudio,
|
||||
&i.QtdEstudio,
|
||||
&i.TipoCartao,
|
||||
&i.Observacao,
|
||||
&i.QualTec,
|
||||
&i.EducacaoSimpatia,
|
||||
&i.DesempenhoEvento,
|
||||
&i.DispHorario,
|
||||
&i.Media,
|
||||
&i.TabelaFree,
|
||||
&i.ExtraPorEquipamento,
|
||||
&i.Equipamentos,
|
||||
&i.Email,
|
||||
&i.AvatarUrl,
|
||||
&i.CriadoEm,
|
||||
&i.AtualizadoEm,
|
||||
&i.Functions,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getProfissionalByID = `-- name: GetProfissionalByID :one
|
||||
SELECT p.id, p.usuario_id, p.nome, p.funcao_profissional_id, p.endereco, p.cidade, p.uf, p.whatsapp, p.cpf_cnpj_titular, p.banco, p.agencia, p.conta_pix, p.carro_disponivel, p.tem_estudio, p.qtd_estudio, p.tipo_cartao, p.observacao, p.qual_tec, p.educacao_simpatia, p.desempenho_evento, p.disp_horario, p.media, p.tabela_free, p.extra_por_equipamento, p.equipamentos, p.email, p.avatar_url, p.criado_em, p.atualizado_em,
|
||||
COALESCE(
|
||||
|
|
@ -329,6 +413,20 @@ func (q *Queries) GetProfissionalByUsuarioID(ctx context.Context, usuarioID pgty
|
|||
return i, err
|
||||
}
|
||||
|
||||
const linkUserToProfessional = `-- name: LinkUserToProfessional :exec
|
||||
UPDATE cadastro_profissionais SET usuario_id = $2 WHERE id = $1
|
||||
`
|
||||
|
||||
type LinkUserToProfessionalParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
UsuarioID pgtype.UUID `json:"usuario_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) LinkUserToProfessional(ctx context.Context, arg LinkUserToProfessionalParams) error {
|
||||
_, err := q.db.Exec(ctx, linkUserToProfessional, arg.ID, arg.UsuarioID)
|
||||
return err
|
||||
}
|
||||
|
||||
const listProfissionais = `-- name: ListProfissionais :many
|
||||
SELECT p.id, p.usuario_id, p.nome, p.funcao_profissional_id, p.endereco, p.cidade, p.uf, p.whatsapp, p.cpf_cnpj_titular, p.banco, p.agencia, p.conta_pix, p.carro_disponivel, p.tem_estudio, p.qtd_estudio, p.tipo_cartao, p.observacao, p.qual_tec, p.educacao_simpatia, p.desempenho_evento, p.disp_horario, p.media, p.tabela_free, p.extra_por_equipamento, p.equipamentos, p.email, p.avatar_url, p.criado_em, p.atualizado_em, u.email as usuario_email,
|
||||
COALESCE(
|
||||
|
|
|
|||
|
|
@ -131,3 +131,19 @@ DELETE FROM profissionais_funcoes_junction WHERE profissional_id = $1;
|
|||
|
||||
-- name: DeleteProfessionalFunctions :exec
|
||||
DELETE FROM profissionais_funcoes_junction WHERE profissional_id = $1;
|
||||
|
||||
-- name: GetProfissionalByCPF :one
|
||||
SELECT p.*,
|
||||
COALESCE(
|
||||
(SELECT json_agg(json_build_object('id', f.id, 'nome', f.nome))
|
||||
FROM profissionais_funcoes_junction pfj
|
||||
JOIN funcoes_profissionais f ON pfj.funcao_id = f.id
|
||||
WHERE pfj.profissional_id = p.id
|
||||
), '[]'::json
|
||||
) as functions
|
||||
FROM cadastro_profissionais p
|
||||
WHERE p.cpf_cnpj_titular = $1 LIMIT 1;
|
||||
|
||||
-- name: LinkUserToProfessional :exec
|
||||
UPDATE cadastro_profissionais SET usuario_id = $2 WHERE id = $1;
|
||||
|
||||
|
|
|
|||
|
|
@ -485,3 +485,11 @@ DO $$
|
|||
BEGIN
|
||||
ALTER TABLE cadastro_fot ALTER COLUMN gastos_captacao TYPE NUMERIC(20, 2);
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name='unique_cpf_cnpj_titular' AND table_name='cadastro_profissionais') THEN
|
||||
ALTER TABLE cadastro_profissionais ADD CONSTRAINT unique_cpf_cnpj_titular UNIQUE (cpf_cnpj_titular);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
|
|
|
|||
|
|
@ -462,3 +462,89 @@ func (h *Handler) Delete(c *gin.Context) {
|
|||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||||
}
|
||||
|
||||
// Import godoc
|
||||
// @Summary Import professionals from properties
|
||||
// @Description Import professionals (Upsert by CPF)
|
||||
// @Tags profissionais
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param request body []CreateProfissionalInput true "List of Professionals"
|
||||
// @Success 200 {object} ImportStats
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/profissionais/import [post]
|
||||
func (h *Handler) Import(c *gin.Context) {
|
||||
var input []CreateProfissionalInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Role check: Only ADMIN or OWNER
|
||||
userRole, exists := c.Get("role")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
roleStr := userRole.(string)
|
||||
if roleStr != "SUPERADMIN" && roleStr != "BUSINESS_OWNER" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
|
||||
return
|
||||
}
|
||||
|
||||
stats, errs := h.service.Import(c.Request.Context(), input)
|
||||
|
||||
// Construct response with errors if any
|
||||
response := gin.H{
|
||||
"created": stats.Created,
|
||||
"updated": stats.Updated,
|
||||
"errors_count": stats.Errors,
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
var errMsgs []string
|
||||
for _, e := range errs {
|
||||
errMsgs = append(errMsgs, e.Error())
|
||||
}
|
||||
response["errors"] = errMsgs
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// CheckCPF godoc
|
||||
// @Summary Check if professional exists by CPF
|
||||
// @Description Check existence and claim status
|
||||
// @Tags profissionais
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param cpf query string true "CPF/CNPJ"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Router /api/public/profissionais/check [get]
|
||||
func (h *Handler) CheckCPF(c *gin.Context) {
|
||||
cpf := c.Query("cpf")
|
||||
if cpf == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "cpf required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Clean CPF (optional, but good practice)
|
||||
// For now rely on exact match or client cleaning.
|
||||
// Ideally service should clean.
|
||||
|
||||
exists, claimed, name, err := h.service.CheckExistence(c.Request.Context(), cpf)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Return limited info for public check
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"exists": exists,
|
||||
"claimed": claimed,
|
||||
"name": name, // Returning name might be PII, but usually acceptable for confirmation "Is this you?"
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ type CreateProfissionalInput struct {
|
|||
TargetUserID *string `json:"target_user_id"` // Optional: For admin creation
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, userID string, input CreateProfissionalInput) (*generated.CadastroProfissionai, error) {
|
||||
func (s *Service) Create(ctx context.Context, userID string, input CreateProfissionalInput) (*generated.GetProfissionalByIDRow, error) {
|
||||
finalUserID := userID
|
||||
if input.TargetUserID != nil && *input.TargetUserID != "" {
|
||||
finalUserID = *input.TargetUserID
|
||||
|
|
@ -60,6 +60,112 @@ func (s *Service) Create(ctx context.Context, userID string, input CreateProfiss
|
|||
return nil, errors.New("invalid usuario_id")
|
||||
}
|
||||
|
||||
// Check if CPF/CNPJ is provided and if it exists (Claim flow)
|
||||
if input.CpfCnpjTitular != nil && *input.CpfCnpjTitular != "" {
|
||||
existing, err := s.queries.GetProfissionalByCPF(ctx, pgtype.Text{String: *input.CpfCnpjTitular, Valid: true})
|
||||
if err == nil {
|
||||
// Found! Check if already claimed
|
||||
if existing.UsuarioID.Valid {
|
||||
return nil, errors.New("professional with this CPF already has a user associated")
|
||||
}
|
||||
|
||||
// Not claimed -> Link and Update
|
||||
// Helper for merging (prefer new input, fallback to existing)
|
||||
mergeStr := func(newVal *string, oldVal pgtype.Text) *string {
|
||||
if newVal != nil {
|
||||
return newVal
|
||||
}
|
||||
if oldVal.Valid {
|
||||
s := oldVal.String
|
||||
return &s
|
||||
}
|
||||
return nil
|
||||
}
|
||||
mergeInt := func(newVal *int, oldVal pgtype.Int4) *int {
|
||||
if newVal != nil {
|
||||
return newVal
|
||||
}
|
||||
if oldVal.Valid {
|
||||
i := int(oldVal.Int32)
|
||||
return &i
|
||||
}
|
||||
return nil
|
||||
}
|
||||
mergeBool := func(newVal *bool, oldVal pgtype.Bool) *bool {
|
||||
if newVal != nil {
|
||||
return newVal
|
||||
}
|
||||
if oldVal.Valid {
|
||||
b := oldVal.Bool
|
||||
return &b
|
||||
}
|
||||
return nil
|
||||
}
|
||||
mergeFloat := func(newVal *float64, oldVal pgtype.Numeric) *float64 {
|
||||
if newVal != nil {
|
||||
return newVal
|
||||
}
|
||||
if oldVal.Valid {
|
||||
f, _ := oldVal.Float64Value()
|
||||
v := f.Float64
|
||||
return &v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Use Update Logic with Merging
|
||||
updateInput := UpdateProfissionalInput{
|
||||
Nome: input.Nome,
|
||||
FuncaoProfissionalID: input.FuncaoProfissionalID,
|
||||
FuncoesIds: input.FuncoesIds,
|
||||
Endereco: mergeStr(input.Endereco, existing.Endereco),
|
||||
Cidade: mergeStr(input.Cidade, existing.Cidade),
|
||||
Uf: mergeStr(input.Uf, existing.Uf),
|
||||
Whatsapp: mergeStr(input.Whatsapp, existing.Whatsapp),
|
||||
CpfCnpjTitular: mergeStr(input.CpfCnpjTitular, existing.CpfCnpjTitular),
|
||||
Banco: mergeStr(input.Banco, existing.Banco),
|
||||
Agencia: mergeStr(input.Agencia, existing.Agencia),
|
||||
ContaPix: mergeStr(input.ContaPix, existing.ContaPix),
|
||||
CarroDisponivel: mergeBool(input.CarroDisponivel, existing.CarroDisponivel),
|
||||
TemEstudio: mergeBool(input.TemEstudio, existing.TemEstudio),
|
||||
QtdEstudio: mergeInt(input.QtdEstudio, existing.QtdEstudio),
|
||||
TipoCartao: mergeStr(input.TipoCartao, existing.TipoCartao),
|
||||
Observacao: mergeStr(input.Observacao, existing.Observacao),
|
||||
QualTec: mergeInt(input.QualTec, existing.QualTec),
|
||||
EducacaoSimpatia: mergeInt(input.EducacaoSimpatia, existing.EducacaoSimpatia),
|
||||
DesempenhoEvento: mergeInt(input.DesempenhoEvento, existing.DesempenhoEvento),
|
||||
DispHorario: mergeInt(input.DispHorario, existing.DispHorario),
|
||||
Media: mergeFloat(input.Media, existing.Media),
|
||||
TabelaFree: mergeStr(input.TabelaFree, existing.TabelaFree),
|
||||
ExtraPorEquipamento: mergeBool(input.ExtraPorEquipamento, existing.ExtraPorEquipamento),
|
||||
Equipamentos: mergeStr(input.Equipamentos, existing.Equipamentos),
|
||||
Email: mergeStr(input.Email, existing.Email),
|
||||
AvatarURL: mergeStr(input.AvatarURL, existing.AvatarUrl),
|
||||
}
|
||||
|
||||
// Link User to Professional
|
||||
idStr := uuid.UUID(existing.ID.Bytes).String()
|
||||
err = s.queries.LinkUserToProfessional(ctx, generated.LinkUserToProfessionalParams{
|
||||
ID: existing.ID,
|
||||
UsuarioID: pgtype.UUID{Bytes: usuarioUUID, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Update other fields
|
||||
_, err = s.Update(ctx, idStr, updateInput)
|
||||
if err != nil {
|
||||
// Should rollback link? Ideally within transaction.
|
||||
// For now, return error.
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Return updated professional (fetch again to be sure or use return from Update)
|
||||
return s.GetByID(ctx, idStr)
|
||||
}
|
||||
}
|
||||
|
||||
var funcaoUUID uuid.UUID
|
||||
var funcaoValid bool
|
||||
if input.FuncaoProfissionalID != "" {
|
||||
|
|
@ -126,7 +232,8 @@ func (s *Service) Create(ctx context.Context, userID string, input CreateProfiss
|
|||
})
|
||||
}
|
||||
|
||||
return &prof, nil
|
||||
// Fetch full object to return
|
||||
return s.GetByID(ctx, uuid.UUID(prof.ID.Bytes).String())
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context) ([]generated.ListProfissionaisRow, error) {
|
||||
|
|
@ -337,3 +444,176 @@ func toPgNumeric(f *float64) pgtype.Numeric {
|
|||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// Import Logic
|
||||
|
||||
type ImportProfessionalInput struct {
|
||||
CreateProfissionalInput
|
||||
// Any extra fields? Maybe not.
|
||||
}
|
||||
|
||||
type ImportStats struct {
|
||||
Created int
|
||||
Updated int
|
||||
Errors int
|
||||
}
|
||||
|
||||
func (s *Service) Import(ctx context.Context, items []CreateProfissionalInput) (ImportStats, []error) {
|
||||
stats := ImportStats{}
|
||||
var errs []error
|
||||
|
||||
for i, input := range items {
|
||||
// 1. Validate / Normalize CPF
|
||||
if input.CpfCnpjTitular == nil || *input.CpfCnpjTitular == "" {
|
||||
errs = append(errs, fmt.Errorf("row %d: cpf is required", i))
|
||||
stats.Errors++
|
||||
continue
|
||||
}
|
||||
|
||||
cpf := *input.CpfCnpjTitular
|
||||
|
||||
// 2. Check if exists
|
||||
existing, err := s.queries.GetProfissionalByCPF(ctx, pgtype.Text{String: cpf, Valid: true})
|
||||
if err == nil {
|
||||
// Found -> Update
|
||||
// Map input to UpdateProfissionalInput
|
||||
// Using a helper or manual mapping
|
||||
updateInput := UpdateProfissionalInput{
|
||||
Nome: input.Nome,
|
||||
FuncaoProfissionalID: input.FuncaoProfissionalID,
|
||||
FuncoesIds: input.FuncoesIds,
|
||||
Endereco: input.Endereco,
|
||||
Cidade: input.Cidade,
|
||||
Uf: input.Uf,
|
||||
Whatsapp: input.Whatsapp,
|
||||
CpfCnpjTitular: input.CpfCnpjTitular,
|
||||
Banco: input.Banco,
|
||||
Agencia: input.Agencia,
|
||||
ContaPix: input.ContaPix,
|
||||
CarroDisponivel: input.CarroDisponivel,
|
||||
TemEstudio: input.TemEstudio,
|
||||
QtdEstudio: input.QtdEstudio,
|
||||
TipoCartao: input.TipoCartao,
|
||||
Observacao: input.Observacao,
|
||||
QualTec: input.QualTec,
|
||||
EducacaoSimpatia: input.EducacaoSimpatia,
|
||||
DesempenhoEvento: input.DesempenhoEvento,
|
||||
DispHorario: input.DispHorario,
|
||||
Media: input.Media,
|
||||
TabelaFree: input.TabelaFree,
|
||||
ExtraPorEquipamento: input.ExtraPorEquipamento,
|
||||
Equipamentos: input.Equipamentos,
|
||||
Email: input.Email,
|
||||
AvatarURL: input.AvatarURL,
|
||||
}
|
||||
// Use existing ID
|
||||
idStr := uuid.UUID(existing.ID.Bytes).String()
|
||||
_, err := s.Update(ctx, idStr, updateInput)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("row %d (update): %v", i, err))
|
||||
stats.Errors++
|
||||
} else {
|
||||
stats.Updated++
|
||||
}
|
||||
} else {
|
||||
// Not Found (or error) -> Check if it is Not Found
|
||||
// sqlc returns err on Not Found? likely pgx.ErrNoRows or similar
|
||||
// If it's real error, log it. But for now assume err means Not Found.
|
||||
|
||||
// Create New
|
||||
// Use empty userID (or nil)
|
||||
// Create function takes userID string.
|
||||
// I need to adjust Create to accept nullable userID or pass "" and handle it.
|
||||
// Looking at Create code:
|
||||
// uuid.Parse(finalUserID). If "" -> error "invalid usuario_id"
|
||||
// So Create DOES NOT support empty user ID currently.
|
||||
|
||||
// I need to Modify Create to support NULL user_id.
|
||||
|
||||
// For now, I'll direct call queries.CreateProfissional here to bypass service check, OR fix Service.Create.
|
||||
// Fixing Service.Create is better.
|
||||
|
||||
// Workaround: Call logic directly here for creation without User
|
||||
// Copy Logic from Create but allow UserID to be invalid/null
|
||||
|
||||
var funcaoUUID uuid.UUID
|
||||
var funcaoValid bool
|
||||
if input.FuncaoProfissionalID != "" {
|
||||
parsed, err := uuid.Parse(input.FuncaoProfissionalID)
|
||||
if err == nil {
|
||||
funcaoUUID = parsed
|
||||
funcaoValid = true
|
||||
}
|
||||
}
|
||||
|
||||
params := generated.CreateProfissionalParams{
|
||||
UsuarioID: pgtype.UUID{Valid: false}, // Is NULL
|
||||
Nome: input.Nome,
|
||||
FuncaoProfissionalID: pgtype.UUID{Bytes: funcaoUUID, Valid: funcaoValid},
|
||||
Endereco: toPgText(input.Endereco),
|
||||
Cidade: toPgText(input.Cidade),
|
||||
Uf: toPgText(input.Uf),
|
||||
Whatsapp: toPgText(input.Whatsapp),
|
||||
CpfCnpjTitular: toPgText(input.CpfCnpjTitular),
|
||||
Banco: toPgText(input.Banco),
|
||||
Agencia: toPgText(input.Agencia),
|
||||
ContaPix: toPgText(input.ContaPix),
|
||||
CarroDisponivel: toPgBool(input.CarroDisponivel),
|
||||
TemEstudio: toPgBool(input.TemEstudio),
|
||||
QtdEstudio: toPgInt4(input.QtdEstudio),
|
||||
TipoCartao: toPgText(input.TipoCartao),
|
||||
Observacao: toPgText(input.Observacao),
|
||||
QualTec: toPgInt4(input.QualTec),
|
||||
EducacaoSimpatia: toPgInt4(input.EducacaoSimpatia),
|
||||
DesempenhoEvento: toPgInt4(input.DesempenhoEvento),
|
||||
DispHorario: toPgInt4(input.DispHorario),
|
||||
Media: toPgNumeric(input.Media),
|
||||
TabelaFree: toPgText(input.TabelaFree),
|
||||
ExtraPorEquipamento: toPgBool(input.ExtraPorEquipamento),
|
||||
Equipamentos: toPgText(input.Equipamentos),
|
||||
Email: toPgText(input.Email),
|
||||
AvatarUrl: toPgText(input.AvatarURL),
|
||||
}
|
||||
|
||||
prof, err := s.queries.CreateProfissional(ctx, params)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("row %d (create): %v", i, err))
|
||||
stats.Errors++
|
||||
} else {
|
||||
// Insert multiple functions if provided
|
||||
if len(input.FuncoesIds) > 0 {
|
||||
for _, fid := range input.FuncoesIds {
|
||||
fUUID, err := uuid.Parse(fid)
|
||||
if err == nil {
|
||||
_ = s.queries.AddFunctionToProfessional(ctx, generated.AddFunctionToProfessionalParams{
|
||||
ProfissionalID: pgtype.UUID{Bytes: prof.ID.Bytes, Valid: true},
|
||||
FuncaoID: pgtype.UUID{Bytes: fUUID, Valid: true},
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if funcaoValid {
|
||||
_ = s.queries.AddFunctionToProfessional(ctx, generated.AddFunctionToProfessionalParams{
|
||||
ProfissionalID: pgtype.UUID{Bytes: prof.ID.Bytes, Valid: true},
|
||||
FuncaoID: pgtype.UUID{Bytes: funcaoUUID, Valid: true},
|
||||
})
|
||||
}
|
||||
stats.Created++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return stats, errs
|
||||
}
|
||||
|
||||
// CheckExistence checks if a professional exists by CPF
|
||||
func (s *Service) CheckExistence(ctx context.Context, cpf string) (exists bool, claimed bool, name string, err error) {
|
||||
prof, err := s.queries.GetProfissionalByCPF(ctx, pgtype.Text{String: cpf, Valid: true})
|
||||
if err != nil {
|
||||
if err.Error() == "no rows in result set" {
|
||||
return false, false, "", nil
|
||||
}
|
||||
return false, false, "", err
|
||||
}
|
||||
|
||||
return true, prof.UsuarioID.Valid, prof.Nome, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -548,9 +548,7 @@ const AppContent: React.FC = () => {
|
|||
const location = useLocation();
|
||||
const showFooter = location.pathname === "/";
|
||||
const { isLoading: isAuthLoading } = useAuth();
|
||||
const { isLoading: isDataLoading } = useData();
|
||||
|
||||
if (isAuthLoading || isDataLoading) {
|
||||
if (isAuthLoading) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ interface EventTableProps {
|
|||
isManagingTeam?: boolean; // Nova prop para determinar se está na tela de gerenciar equipe
|
||||
professionals?: any[]; // Lista de profissionais para cálculos de gestão de equipe
|
||||
functions?: any[]; // Lista de funções disponíveis
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
type SortField =
|
||||
|
|
@ -38,6 +39,7 @@ export const EventTable: React.FC<EventTableProps> = ({
|
|||
isManagingTeam = false,
|
||||
professionals = [],
|
||||
functions = [],
|
||||
isLoading = false,
|
||||
}) => {
|
||||
const canApprove = isManagingTeam && (userRole === UserRole.BUSINESS_OWNER || userRole === UserRole.SUPERADMIN);
|
||||
const canReject = userRole === UserRole.BUSINESS_OWNER || userRole === UserRole.SUPERADMIN;
|
||||
|
|
@ -376,6 +378,14 @@ export const EventTable: React.FC<EventTableProps> = ({
|
|||
<div style={{ width: tableScrollWidth > 0 ? tableScrollWidth : '100%', height: '1px' }}></div>
|
||||
</div>
|
||||
|
||||
<div className="hidden md:block">
|
||||
<PaginationControls
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="hidden md:block overflow-x-auto"
|
||||
ref={tableContainerRef}
|
||||
|
|
@ -505,7 +515,17 @@ export const EventTable: React.FC<EventTableProps> = ({
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{paginatedEvents.map((event) => {
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={12} className="py-20 text-center">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<div className="w-10 h-10 border-4 border-gray-100 rounded-full border-t-brand-gold animate-spin mb-4"></div>
|
||||
<p className="text-gray-500 font-medium">Carregando dados...</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
paginatedEvents.map((event) => {
|
||||
// Logic to find photographer assignment status
|
||||
let photographerAssignment = null;
|
||||
if (isPhotographer && currentProfessionalId && event.assignments) {
|
||||
|
|
@ -721,24 +741,49 @@ export const EventTable: React.FC<EventTableProps> = ({
|
|||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-white border-t border-gray-200 sm:px-6">
|
||||
<PaginationControls
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
|
||||
{sortedEvents.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<p>Nenhum evento encontrado.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface PaginationControlsProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
const PaginationControls: React.FC<PaginationControlsProps> = ({ currentPage, totalPages, onPageChange }) => {
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-white border-t border-b border-gray-200 sm:px-6">
|
||||
<div className="flex justify-between w-full sm:hidden">
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Anterior
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="relative ml-3 inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
|
|
@ -755,7 +800,7 @@ export const EventTable: React.FC<EventTableProps> = ({
|
|||
<div>
|
||||
<nav className="inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0 disabled:opacity-50"
|
||||
>
|
||||
|
|
@ -776,7 +821,7 @@ export const EventTable: React.FC<EventTableProps> = ({
|
|||
return (
|
||||
<button
|
||||
key={pageNum}
|
||||
onClick={() => setCurrentPage(pageNum)}
|
||||
onClick={() => onPageChange(pageNum)}
|
||||
className={`relative inline-flex items-center px-4 py-2 text-sm font-semibold ${currentPage === pageNum ? 'bg-brand-gold text-white focus:z-20 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-gold' : 'text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0'}`}
|
||||
>
|
||||
{pageNum}
|
||||
|
|
@ -784,7 +829,7 @@ export const EventTable: React.FC<EventTableProps> = ({
|
|||
)
|
||||
})}
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0 disabled:opacity-50"
|
||||
>
|
||||
|
|
@ -797,13 +842,5 @@ export const EventTable: React.FC<EventTableProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sortedEvents.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<p>Nenhum evento encontrado.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -114,6 +114,32 @@ export const ProfessionalForm: React.FC<ProfessionalFormProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
const handleCpfBlur = async () => {
|
||||
const cpf = formData.cpfCnpj.replace(/\D/g, "");
|
||||
if (cpf.length < 11) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL || "http://localhost:8080"}/api/profissionais/check?cpf=${cpf}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.exists) {
|
||||
if (data.claimed) {
|
||||
alert(`O CPF ${cpf} já está cadastrado e possui um usuário associado. Por favor, faça login.`);
|
||||
window.location.href = "/entrar";
|
||||
} else {
|
||||
alert(`Encontramos um cadastro para o CPF ${cpf} (${data.name}). Continue para criar seu acesso.`);
|
||||
// Optional: Autofill name?
|
||||
setFormData(prev => ({ ...prev, nome: data.name || prev.nome }));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erro ao verificar CPF:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCepBlur = async () => {
|
||||
const cep = formData.cep.replace(/\D/g, "");
|
||||
if (cep.length !== 8) return;
|
||||
|
|
@ -249,6 +275,43 @@ export const ProfessionalForm: React.FC<ProfessionalFormProps> = ({
|
|||
onChange={(e) => handleChange("nome", e.target.value)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
CPF ou CNPJ do Titular *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.cpfCnpj}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value.replace(/\D/g, "");
|
||||
let formatted = "";
|
||||
if (value.length <= 11) {
|
||||
// CPF: 000.000.000-00
|
||||
formatted = value.replace(
|
||||
/(\d{3})(\d{3})(\d{3})(\d{0,2})/,
|
||||
"$1.$2.$3-$4"
|
||||
);
|
||||
} else {
|
||||
// CNPJ: 00.000.000/0000-00
|
||||
formatted = value
|
||||
.slice(0, 14)
|
||||
.replace(
|
||||
/(\d{2})(\d{3})(\d{3})(\d{4})(\d{0,2})/,
|
||||
"$1.$2.$3/$4-$5"
|
||||
);
|
||||
}
|
||||
handleChange("cpfCnpj", formatted);
|
||||
}}
|
||||
onBlur={handleCpfBlur}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#B9CF33] focus:border-transparent"
|
||||
placeholder="000.000.000-00 ou 00.000.000/0000-00"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Informe seu CPF para verificarmos se você já possui cadastro.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Foto de Perfil *
|
||||
|
|
@ -489,36 +552,7 @@ export const ProfessionalForm: React.FC<ProfessionalFormProps> = ({
|
|||
</h3>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
CPF ou CNPJ do Titular da Conta *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.cpfCnpj}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value.replace(/\D/g, "");
|
||||
let formatted = "";
|
||||
if (value.length <= 11) {
|
||||
// CPF: 000.000.000-00
|
||||
formatted = value.replace(
|
||||
/(\d{3})(\d{3})(\d{3})(\d{0,2})/,
|
||||
"$1.$2.$3-$4"
|
||||
);
|
||||
} else {
|
||||
// CNPJ: 00.000.000/0000-00
|
||||
formatted = value
|
||||
.slice(0, 14)
|
||||
.replace(
|
||||
/(\d{2})(\d{3})(\d{3})(\d{4})(\d{0,2})/,
|
||||
"$1.$2.$3/$4-$5"
|
||||
);
|
||||
}
|
||||
handleChange("cpfCnpj", formatted);
|
||||
}}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#B9CF33] focus:border-transparent"
|
||||
placeholder="000.000.000-00 ou 00.000.000/0000-00"
|
||||
/>
|
||||
{/* CPF moved to personal info */}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
|
|
|
|||
638
frontend/components/ProfessionalModal.tsx
Normal file
638
frontend/components/ProfessionalModal.tsx
Normal file
|
|
@ -0,0 +1,638 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
X,
|
||||
User,
|
||||
Camera,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from "lucide-react";
|
||||
import { Button } from "./Button";
|
||||
import {
|
||||
createProfessional,
|
||||
updateProfessional,
|
||||
getUploadURL,
|
||||
uploadFileToSignedUrl,
|
||||
} from "../services/apiService";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { Professional, CreateProfessionalDTO } from "../types";
|
||||
|
||||
interface ProfessionalModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
professional: Professional | null; // If null, it's Add Mode
|
||||
existingProfessionals?: Professional[];
|
||||
onSwitchToEdit?: (prof: Professional) => void;
|
||||
roles: { id: string; nome: string }[];
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export const ProfessionalModal: React.FC<ProfessionalModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
professional,
|
||||
existingProfessionals = [],
|
||||
onSwitchToEdit,
|
||||
roles,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const { token: contextToken } = useAuth();
|
||||
const token = contextToken || "";
|
||||
|
||||
const initialFormState: CreateProfessionalDTO & { senha?: string; confirmarSenha?: string } = {
|
||||
nome: "",
|
||||
funcao_profissional_id: "",
|
||||
funcoes_ids: [],
|
||||
email: "",
|
||||
senha: "",
|
||||
confirmarSenha: "",
|
||||
whatsapp: "",
|
||||
cpf_cnpj_titular: "",
|
||||
endereco: "",
|
||||
cidade: "",
|
||||
uf: "",
|
||||
banco: "",
|
||||
agencia: "",
|
||||
conta_pix: "",
|
||||
tipo_cartao: "",
|
||||
carro_disponivel: false,
|
||||
tem_estudio: false,
|
||||
qtd_estudio: 0,
|
||||
observacao: "",
|
||||
qual_tec: 0,
|
||||
educacao_simpatia: 0,
|
||||
desempenho_evento: 0,
|
||||
disp_horario: 0,
|
||||
media: 0,
|
||||
tabela_free: "",
|
||||
extra_por_equipamento: false,
|
||||
equipamentos: "",
|
||||
avatar_url: "",
|
||||
};
|
||||
|
||||
const [formData, setFormData] = useState<CreateProfessionalDTO & { senha?: string; confirmarSenha?: string }>(initialFormState);
|
||||
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||||
const [avatarPreview, setAvatarPreview] = useState<string>("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isLoadingCep, setIsLoadingCep] = useState(false);
|
||||
|
||||
const GenericAvatar = "https://ui-avatars.com/api/?background=random";
|
||||
const ufs = ["AC", "AL", "AP", "AM", "BA", "CE", "DF", "ES", "GO", "MA", "MT", "MS", "MG", "PA", "PB", "PR", "PE", "PI", "RJ", "RN", "RS", "RO", "RR", "SC", "SP", "SE", "TO"];
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (professional) {
|
||||
// Edit Mode
|
||||
setFormData({
|
||||
nome: professional.nome,
|
||||
funcao_profissional_id: professional.funcao_profissional_id,
|
||||
funcoes_ids: professional.functions?.map(f => f.id) || (professional.funcao_profissional_id ? [professional.funcao_profissional_id] : []),
|
||||
email: professional.email || "",
|
||||
senha: "",
|
||||
confirmarSenha: "",
|
||||
whatsapp: professional.whatsapp || "",
|
||||
cpf_cnpj_titular: professional.cpf_cnpj_titular || "",
|
||||
endereco: professional.endereco || "",
|
||||
cidade: professional.cidade || "",
|
||||
uf: professional.uf || "",
|
||||
cep: professional.cep || "",
|
||||
banco: professional.banco || "",
|
||||
agencia: professional.agencia || "",
|
||||
conta_pix: professional.conta_pix || "",
|
||||
tipo_cartao: professional.tipo_cartao || "",
|
||||
carro_disponivel: professional.carro_disponivel || false,
|
||||
tem_estudio: professional.tem_estudio || false,
|
||||
qtd_estudio: professional.qtd_estudio || 0,
|
||||
observacao: professional.observacao || "",
|
||||
qual_tec: professional.qual_tec || 0,
|
||||
educacao_simpatia: professional.educacao_simpatia || 0,
|
||||
desempenho_evento: professional.desempenho_evento || 0,
|
||||
disp_horario: professional.disp_horario || 0,
|
||||
tabela_free: professional.tabela_free || "",
|
||||
extra_por_equipamento: professional.extra_por_equipamento || false,
|
||||
equipamentos: professional.equipamentos || "",
|
||||
avatar_url: professional.avatar_url || "",
|
||||
media: professional.media || 0,
|
||||
});
|
||||
setAvatarPreview(professional.avatar_url || (professional.avatar ?? GenericAvatar));
|
||||
} else {
|
||||
// Add Mode
|
||||
setFormData(initialFormState);
|
||||
setAvatarPreview("");
|
||||
}
|
||||
setAvatarFile(null);
|
||||
}
|
||||
}, [isOpen, professional]);
|
||||
|
||||
// Helpers
|
||||
const maskPhone = (value: string) => {
|
||||
return value
|
||||
.replace(/\D/g, "")
|
||||
.replace(/^(\d{2})(\d)/g, "($1) $2")
|
||||
.replace(/(\d)(\d{4})$/, "$1-$2")
|
||||
.slice(0, 15);
|
||||
};
|
||||
|
||||
const maskCpfCnpj = (value: string) => {
|
||||
const clean = value.replace(/\D/g, "");
|
||||
if (clean.length <= 11) {
|
||||
return clean
|
||||
.replace(/(\d{3})(\d)/, "$1.$2")
|
||||
.replace(/(\d{3})(\d)/, "$1.$2")
|
||||
.replace(/(\d{3})(\d{1,2})/, "$1-$2")
|
||||
.replace(/(-\d{2})\d+?$/, "$1");
|
||||
} else {
|
||||
return clean
|
||||
.replace(/^(\d{2})(\d)/, "$1.$2")
|
||||
.replace(/^(\d{2})\.(\d{3})(\d)/, "$1.$2.$3")
|
||||
.replace(/\.(\d{3})(\d)/, ".$1/$2")
|
||||
.replace(/(\d{4})(\d)/, "$1-$2")
|
||||
.replace(/(-\d{2})\d+?$/, "$1")
|
||||
.slice(0, 18);
|
||||
}
|
||||
};
|
||||
|
||||
const calculateMedia = (ratings: {
|
||||
qual_tec: number;
|
||||
educacao_simpatia: number;
|
||||
desempenho_evento: number;
|
||||
disp_horario: number;
|
||||
}) => {
|
||||
const weightedScore =
|
||||
ratings.qual_tec * 2 +
|
||||
ratings.educacao_simpatia +
|
||||
ratings.desempenho_evento +
|
||||
ratings.disp_horario;
|
||||
return weightedScore / 5;
|
||||
};
|
||||
|
||||
// Listen for rating changes
|
||||
useEffect(() => {
|
||||
const newMedia = calculateMedia({
|
||||
qual_tec: formData.qual_tec || 0,
|
||||
educacao_simpatia: formData.educacao_simpatia || 0,
|
||||
desempenho_evento: formData.desempenho_evento || 0,
|
||||
disp_horario: formData.disp_horario || 0,
|
||||
});
|
||||
setFormData((prev) => {
|
||||
if (prev.media === newMedia) return prev;
|
||||
return { ...prev, media: newMedia };
|
||||
});
|
||||
}, [
|
||||
formData.qual_tec,
|
||||
formData.educacao_simpatia,
|
||||
formData.desempenho_evento,
|
||||
formData.disp_horario,
|
||||
]);
|
||||
|
||||
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setAvatarFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setAvatarPreview(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const removeAvatar = () => {
|
||||
setAvatarFile(null);
|
||||
setAvatarPreview("");
|
||||
setFormData((prev) => ({ ...prev, avatar_url: "" }));
|
||||
};
|
||||
|
||||
const handleCpfBlur = async () => {
|
||||
const cpf = formData.cpf_cnpj_titular?.replace(/\D/g, "") || "";
|
||||
if (cpf.length < 11) return;
|
||||
|
||||
// 1. Check Local Existence (Admin / TeamPage)
|
||||
if (existingProfessionals.length > 0 && !professional) {
|
||||
const found = existingProfessionals.find(p => {
|
||||
const pCpf = p.cpf_cnpj_titular?.replace(/\D/g, "");
|
||||
return pCpf === cpf;
|
||||
});
|
||||
|
||||
if (found) {
|
||||
if (confirm(`O profissional "${found.nome}" já está cadastrado com este CPF.\nDeseja abrir o cadastro existente para edição?`)) {
|
||||
if (onSwitchToEdit) {
|
||||
onSwitchToEdit(found);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// User said No to edit. They probably want to create NEW but with same CPF?
|
||||
// That's impossible due to unique constraint.
|
||||
// Warn them.
|
||||
alert("Atenção: Você não conseguirá salvar um novo profissional com o mesmo CPF.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check API (for safety or if list is incomplete)
|
||||
try {
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL || "http://localhost:8080"}/api/profissionais/check?cpf=${cpf}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.exists) {
|
||||
// If we are here, matched in API but maybe NOT in local list (if pagination existed)
|
||||
// Or maybe user clicked "No" above.
|
||||
if (!professional && data.name) {
|
||||
// If we didn't switch to edit (e.g. user refused or logic above didn't catch it)
|
||||
// We show the warning.
|
||||
if (!existingProfessionals.some(p => p.cpf_cnpj_titular?.replace(/\D/g,"") === cpf)) {
|
||||
alert(`ATENÇÃO: Este CPF já está cadastrado para o profissional "${data.name}" no banco de dados.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erro ao verificar CPF:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCepBlur = async () => {
|
||||
const cep = formData.cep?.replace(/\D/g, "") || "";
|
||||
if (cep.length !== 8) return;
|
||||
|
||||
setIsLoadingCep(true);
|
||||
try {
|
||||
const response = await fetch(`https://viacep.com.br/ws/${cep}/json/`);
|
||||
if (!response.ok) throw new Error("CEP não encontrado");
|
||||
const data = await response.json();
|
||||
if (data.erro) throw new Error("CEP não encontrado");
|
||||
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
endereco: `${data.logradouro || ""} ${data.bairro ? `- ${data.bairro}` : ""}`.trim() || prev.endereco,
|
||||
cidade: data.localidade || prev.cidade,
|
||||
uf: data.uf || prev.uf,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Erro ao buscar CEP:", error);
|
||||
} finally {
|
||||
setIsLoadingCep(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
if (!professional && (formData.senha || formData.confirmarSenha)) {
|
||||
if (formData.senha !== formData.confirmarSenha) {
|
||||
alert("As senhas não coincidem!");
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
if (formData.senha && formData.senha.length < 6) {
|
||||
alert("A senha deve ter pelo menos 6 caracteres.");
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let finalAvatarUrl = formData.avatar_url;
|
||||
|
||||
if (avatarFile) {
|
||||
const uploadRes = await getUploadURL(avatarFile.name, avatarFile.type);
|
||||
if (uploadRes.data) {
|
||||
await uploadFileToSignedUrl(uploadRes.data.upload_url, avatarFile);
|
||||
finalAvatarUrl = uploadRes.data.public_url;
|
||||
}
|
||||
}
|
||||
|
||||
const payload: any = { ...formData, avatar_url: finalAvatarUrl };
|
||||
delete payload.senha;
|
||||
delete payload.confirmarSenha;
|
||||
|
||||
if (professional) {
|
||||
// Update
|
||||
await updateProfessional(professional.id, payload, token);
|
||||
alert("Profissional atualizado com sucesso!");
|
||||
} else {
|
||||
// Create
|
||||
let targetUserId = "";
|
||||
if (formData.email && formData.senha) {
|
||||
const { adminCreateUser } = await import("../services/apiService");
|
||||
const createRes = await adminCreateUser({
|
||||
email: formData.email,
|
||||
senha: formData.senha,
|
||||
nome: formData.nome,
|
||||
role: roles.find(r => r.id === formData.funcao_profissional_id)?.nome.toUpperCase().includes("PESQUISA") ? "RESEARCHER" : "PHOTOGRAPHER",
|
||||
tipo_profissional: roles.find(r => r.id === formData.funcao_profissional_id)?.nome || "",
|
||||
ativo: true,
|
||||
}, token);
|
||||
|
||||
if (createRes.error) {
|
||||
throw new Error("Erro ao criar usuário de login: " + createRes.error);
|
||||
}
|
||||
if (createRes.data && createRes.data.id) {
|
||||
targetUserId = createRes.data.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetUserId) {
|
||||
payload.target_user_id = targetUserId;
|
||||
}
|
||||
|
||||
const res = await createProfessional(payload, token);
|
||||
if (res.error) throw new Error(res.error);
|
||||
alert("Profissional criado com sucesso!");
|
||||
}
|
||||
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (error: any) {
|
||||
console.error("Error submitting form:", error);
|
||||
alert(error.message || "Erro ao salvar profissional. Verifique o console.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 overflow-y-auto">
|
||||
<div className="bg-white rounded-lg max-w-4xl w-full p-8 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-bold font-serif">{professional ? "Editar Profissional" : "Novo Profissional"}</h2>
|
||||
<button onClick={onClose}><X size={24} /></button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Avatar */}
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="relative">
|
||||
<div className="w-32 h-32 rounded-full overflow-hidden bg-gray-100 border-2 border-dashed border-gray-300 flex items-center justify-center">
|
||||
{avatarPreview ? (
|
||||
<img src={avatarPreview} alt="Preview" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<User size={48} className="text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
<label className="absolute bottom-0 right-0 bg-brand-gold text-white p-2 rounded-full cursor-pointer hover:bg-brand-gold/90 transition-colors shadow-lg">
|
||||
<Camera size={16} />
|
||||
<input type="file" accept="image/*" className="hidden" onChange={handleAvatarChange} />
|
||||
</label>
|
||||
{avatarPreview && (
|
||||
<button type="button" onClick={removeAvatar} className="absolute top-0 right-0 bg-red-500 text-white p-1 rounded-full shadow-lg hover:bg-red-600">
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Name & CPF (Top) */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Nome *</label>
|
||||
<input required type="text" value={formData.nome || ""} onChange={e => setFormData({ ...formData, nome: e.target.value })} className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-brand-gold focus:ring focus:ring-brand-gold focus:ring-opacity-50 p-2 border" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">CPF/CNPJ Titular *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.cpf_cnpj_titular || ""}
|
||||
onChange={e => setFormData({ ...formData, cpf_cnpj_titular: maskCpfCnpj(e.target.value) })}
|
||||
onBlur={handleCpfBlur}
|
||||
maxLength={18}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-brand-gold focus:ring focus:ring-brand-gold focus:ring-opacity-50 p-2 border"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Functions */}
|
||||
<div className="col-span-1 md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Funções *
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-4 bg-gray-50 p-3 rounded border">
|
||||
{roles.map(role => (
|
||||
<label key={role.id} className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
value={role.id}
|
||||
checked={formData.funcoes_ids?.includes(role.id)}
|
||||
onChange={e => {
|
||||
const checked = e.target.checked;
|
||||
const currentIds = formData.funcoes_ids || [];
|
||||
let newIds: string[] = [];
|
||||
if (checked) {
|
||||
newIds = [...currentIds, role.id];
|
||||
} else {
|
||||
newIds = currentIds.filter((id) => id !== role.id);
|
||||
}
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
funcoes_ids: newIds,
|
||||
funcao_profissional_id: newIds.length > 0 ? newIds[0] : ""
|
||||
}));
|
||||
}}
|
||||
className="w-4 h-4 text-brand-gold rounded border-gray-300 focus:ring-brand-gold"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">{role.nome}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email & Pass */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Email *</label>
|
||||
<input required type="email" value={formData.email || ""} onChange={e => setFormData({ ...formData, email: e.target.value })} className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-brand-gold focus:ring focus:ring-brand-gold focus:ring-opacity-50 p-2 border" />
|
||||
</div>
|
||||
|
||||
{!professional && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Senha *</label>
|
||||
<div className="relative mt-1">
|
||||
<input
|
||||
required
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={formData.senha || ""}
|
||||
onChange={e => setFormData({ ...formData, senha: e.target.value })}
|
||||
minLength={6}
|
||||
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-brand-gold focus:ring focus:ring-brand-gold focus:ring-opacity-50 p-2 border pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 px-3 flex items-center text-gray-400 hover:text-gray-600"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Confirmar Senha *</label>
|
||||
<div className="relative mt-1">
|
||||
<input
|
||||
required
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
value={formData.confirmarSenha || ""}
|
||||
onChange={e => setFormData({ ...formData, confirmarSenha: e.target.value })}
|
||||
minLength={6}
|
||||
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-brand-gold focus:ring focus:ring-brand-gold focus:ring-opacity-50 p-2 border pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 px-3 flex items-center text-gray-400 hover:text-gray-600"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
>
|
||||
{showConfirmPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">WhatsApp</label>
|
||||
<input type="text" value={formData.whatsapp || ""} onChange={e => setFormData({ ...formData, whatsapp: maskPhone(e.target.value) })} maxLength={15} className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-brand-gold focus:ring focus:ring-brand-gold focus:ring-opacity-50 p-2 border" />
|
||||
</div>
|
||||
|
||||
|
||||
<h3 className="text-lg font-medium text-gray-900 border-b pb-2 mt-4">Endereço</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">CEP</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
maxLength={9}
|
||||
value={formData.cep || ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value.replace(/\D/g, "").replace(/^(\d{5})(\d)/, "$1-$2");
|
||||
setFormData({ ...formData, cep: val });
|
||||
}}
|
||||
onBlur={handleCepBlur}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-brand-gold focus:ring focus:ring-brand-gold focus:ring-opacity-50 p-2 border"
|
||||
placeholder="00000-000"
|
||||
/>
|
||||
{isLoadingCep && <span className="absolute right-2 top-3 text-xs text-gray-400">Buscando...</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700">Endereço Completo</label>
|
||||
<input type="text" value={formData.endereco || ""} onChange={e => setFormData({ ...formData, endereco: e.target.value })} className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-brand-gold focus:ring focus:ring-brand-gold focus:ring-opacity-50 p-2 border" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Cidade</label>
|
||||
<input type="text" value={formData.cidade || ""} onChange={e => setFormData({ ...formData, cidade: e.target.value })} className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-brand-gold focus:ring focus:ring-brand-gold focus:ring-opacity-50 p-2 border" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">UF</label>
|
||||
<select value={formData.uf || ""} onChange={e => setFormData({ ...formData, uf: e.target.value })} className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-brand-gold focus:ring focus:ring-brand-gold focus:ring-opacity-50 p-2 border">
|
||||
<option value="">UF</option>
|
||||
{ufs.map(uf => <option key={uf} value={uf}>{uf}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Banking */}
|
||||
<h3 className="text-lg font-medium text-gray-900 border-b pb-2 mt-4">Dados Bancários</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Banco *</label>
|
||||
<input type="text" required value={formData.banco || ""} onChange={e => setFormData({ ...formData, banco: e.target.value })} className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-brand-gold focus:ring focus:ring-brand-gold focus:ring-opacity-50 p-2 border" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Agência *</label>
|
||||
<input type="text" required value={formData.agencia || ""} onChange={e => setFormData({ ...formData, agencia: e.target.value })} className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-brand-gold focus:ring focus:ring-brand-gold focus:ring-opacity-50 p-2 border" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Chave Pix / Conta *</label>
|
||||
<input type="text" required value={formData.conta_pix || ""} onChange={e => setFormData({ ...formData, conta_pix: e.target.value })} className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-brand-gold focus:ring focus:ring-brand-gold focus:ring-opacity-50 p-2 border" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Tipo Cartão</label>
|
||||
<input type="text" value={formData.tipo_cartao || ""} onChange={e => setFormData({ ...formData, tipo_cartao: e.target.value })} className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-brand-gold focus:ring focus:ring-brand-gold focus:ring-opacity-50 p-2 border" placeholder="SD, XQD..." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resources */}
|
||||
<h3 className="text-lg font-medium text-gray-900 border-b pb-2 mt-4">Recursos</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" checked={formData.carro_disponivel || false} onChange={e => setFormData({ ...formData, carro_disponivel: e.target.checked })} />
|
||||
<span>Carro Disponível</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" checked={formData.tem_estudio || false} onChange={e => setFormData({ ...formData, tem_estudio: e.target.checked })} />
|
||||
<span>Possui Estúdio</span>
|
||||
</label>
|
||||
{formData.tem_estudio && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Qtd Estúdios</label>
|
||||
<input type="number" min="0" value={formData.qtd_estudio || 0} onChange={e => setFormData({ ...formData, qtd_estudio: Math.max(0, parseInt(e.target.value)) })} className="mt-1 block w-full rounded-md border-gray-300 shadow-sm p-2 border" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Ratings */}
|
||||
<h3 className="text-lg font-medium text-gray-900 border-b pb-2 mt-4">Avaliações e Valores</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1 leading-tight">Qual. Técnica / Aparência</label>
|
||||
<input type="number" min="0" max="5" step="1" value={formData.qual_tec || 0} onChange={e => setFormData({ ...formData, qual_tec: parseInt(e.target.value) || 0 })} className="block w-full rounded-md border-gray-300 shadow-sm p-2 border" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1 leading-tight">Simpatia</label>
|
||||
<input type="number" min="0" max="5" step="1" value={formData.educacao_simpatia || 0} onChange={e => setFormData({ ...formData, educacao_simpatia: parseInt(e.target.value) || 0 })} className="block w-full rounded-md border-gray-300 shadow-sm p-2 border" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1 leading-tight">Desempenho</label>
|
||||
<input type="number" min="0" max="5" step="1" value={formData.desempenho_evento || 0} onChange={e => setFormData({ ...formData, desempenho_evento: parseInt(e.target.value) || 0 })} className="block w-full rounded-md border-gray-300 shadow-sm p-2 border" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1 leading-tight">Disp. Horário</label>
|
||||
<input type="number" min="0" max="5" step="1" value={formData.disp_horario || 0} onChange={e => setFormData({ ...formData, disp_horario: parseInt(e.target.value) || 0 })} className="block w-full rounded-md border-gray-300 shadow-sm p-2 border" />
|
||||
</div>
|
||||
<div className="bg-gray-100 p-2 rounded text-center flex flex-col justify-center">
|
||||
<span className="block text-xs text-gray-500 font-bold uppercase tracking-wider">Média</span>
|
||||
<span className="text-2xl font-bold text-brand-gold">{formData.media ? (typeof formData.media === 'number' ? formData.media.toFixed(1) : parseFloat(String(formData.media)).toFixed(1)) : "0.0"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Valor Tabela Free</label>
|
||||
<input type="text" value={formData.tabela_free || ""} onChange={e => setFormData({ ...formData, tabela_free: e.target.value })} className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-brand-gold focus:ring focus:ring-brand-gold focus:ring-opacity-50 p-2 border" placeholder="Ex: R$ 200,00" />
|
||||
</div>
|
||||
<div className="flex items-center pt-6">
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" checked={formData.extra_por_equipamento || false} onChange={e => setFormData({ ...formData, extra_por_equipamento: e.target.checked })} />
|
||||
<span>Extra por Equipamento</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Equipamentos</label>
|
||||
<textarea rows={3} value={formData.equipamentos || ""} onChange={e => setFormData({ ...formData, equipamentos: e.target.value })} className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-brand-gold focus:ring focus:ring-brand-gold focus:ring-opacity-50 p-2 border" placeholder="Liste os equipamentos..." />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Observações</label>
|
||||
<textarea rows={3} value={formData.observacao || ""} onChange={e => setFormData({ ...formData, observacao: e.target.value })} className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-brand-gold focus:ring focus:ring-brand-gold focus:ring-opacity-50 p-2 border" />
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex justify-end gap-4 pt-4 border-t sticky bottom-0 bg-white">
|
||||
<Button type="button" variant="secondary" onClick={onClose}>Cancelar</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Salvando..." : professional ? "Salvar Alterações" : "Criar Profissional"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -47,6 +47,7 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
|||
respondToAssignment,
|
||||
updateEventDetails,
|
||||
functions,
|
||||
isLoading,
|
||||
} = useData();
|
||||
|
||||
// ... (inside component)
|
||||
|
|
@ -633,6 +634,7 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
|||
isManagingTeam={true} // Permitir aprovação na gestão geral
|
||||
professionals={professionals} // Adicionar lista de profissionais
|
||||
functions={functions}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { Button } from '../components/Button';
|
||||
import { Upload, FileText, CheckCircle, AlertTriangle, Calendar, Database } from 'lucide-react';
|
||||
import { Upload, FileText, CheckCircle, AlertTriangle, Calendar, Database, UserPlus } from 'lucide-react';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8080";
|
||||
|
||||
type ImportType = 'fot' | 'agenda';
|
||||
type ImportType = 'fot' | 'agenda' | 'profissionais';
|
||||
|
||||
interface ImportFotInput {
|
||||
fot: string;
|
||||
|
|
@ -46,21 +46,74 @@ interface ImportAgendaInput {
|
|||
pre_venda: boolean;
|
||||
}
|
||||
|
||||
interface ImportProfissionalInput {
|
||||
id?: string; // Optional for validation display
|
||||
nome: string;
|
||||
funcao_profissional_id: string; // Must resolve to ID
|
||||
endereco: string;
|
||||
cidade: string;
|
||||
uf: string;
|
||||
whatsapp: string;
|
||||
cpf_cnpj_titular: string;
|
||||
banco: string;
|
||||
agencia: string;
|
||||
conta_pix: string;
|
||||
observacao: string;
|
||||
email: string; // Sometimes in PIX column
|
||||
// Add other fields as needed
|
||||
}
|
||||
|
||||
export const ImportData: React.FC = () => {
|
||||
const { token } = useAuth();
|
||||
const [activeTab, setActiveTab] = useState<ImportType>('fot');
|
||||
|
||||
// Generic data state (can be Fot or Agenda)
|
||||
// Generic data state (can be Fot, Agenda, Profissionais)
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [filename, setFilename] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [result, setResult] = useState<{ success: number; errors: string[] } | null>(null);
|
||||
|
||||
// Cache functions for mapping
|
||||
const [functionsMap, setFunctionsMap] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch functions to map Name -> ID
|
||||
const fetchFunctions = async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/funcoes`, {
|
||||
headers: { "Authorization": `Bearer ${token}` }
|
||||
});
|
||||
if (res.ok) {
|
||||
const list = await res.json();
|
||||
const map: Record<string, string> = {};
|
||||
list.forEach((f: any) => {
|
||||
map[f.nome.toLowerCase()] = f.id;
|
||||
map[f.nome] = f.id; // Case sensitive fallback
|
||||
});
|
||||
// Add mappings for variations if common
|
||||
map['fotografo'] = map['fotógrafo'];
|
||||
map['fotógrafa'] = map['fotógrafo'];
|
||||
map['recepcionista'] = map['recepcionista'];
|
||||
// ...
|
||||
setFunctionsMap(map);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch functions", e);
|
||||
}
|
||||
};
|
||||
fetchFunctions();
|
||||
}, [token]);
|
||||
|
||||
|
||||
const [skippedCount, setSkippedCount] = useState(0);
|
||||
|
||||
// Clear data when switching tabs
|
||||
const handleTabChange = (tab: ImportType) => {
|
||||
if (tab !== activeTab) {
|
||||
setActiveTab(tab);
|
||||
setData([]);
|
||||
setSkippedCount(0);
|
||||
setFilename("");
|
||||
setResult(null);
|
||||
}
|
||||
|
|
@ -71,6 +124,7 @@ export const ImportData: React.FC = () => {
|
|||
if (!file) return;
|
||||
|
||||
setFilename(file.name);
|
||||
setSkippedCount(0);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (evt) => {
|
||||
const bstr = evt.target?.result;
|
||||
|
|
@ -80,6 +134,7 @@ export const ImportData: React.FC = () => {
|
|||
const jsonData = XLSX.utils.sheet_to_json(ws, { header: 1 }) as any[][];
|
||||
|
||||
let mappedData: any[] = [];
|
||||
let skipped = 0;
|
||||
|
||||
// Start from row 1 (skip header)
|
||||
for (let i = 1; i < jsonData.length; i++) {
|
||||
|
|
@ -88,6 +143,68 @@ export const ImportData: React.FC = () => {
|
|||
|
||||
// Helper to get string
|
||||
const getStr = (idx: number) => row[idx] ? String(row[idx]).trim() : "";
|
||||
|
||||
// Helper to extract/clean CPF/CNPJ
|
||||
const cleanCpf = (raw: string): string => {
|
||||
if (!raw) return "";
|
||||
|
||||
// 1. If explicit "CPF" or "CNPJ" label exists, prioritize digits after it
|
||||
const labelMatch = raw.match(/(?:cpf|cnpj)[\s:./-]*([\d.-]+)/i);
|
||||
if (labelMatch && labelMatch[1]) {
|
||||
const digits = labelMatch[1].replace(/\D/g, '');
|
||||
if (digits.length === 11 || digits.length === 14) return digits; // Return digits only? Or formatted?
|
||||
// Let's return formatted if possible or just digits.
|
||||
// Storing digits-only is safer for matching.
|
||||
return digits;
|
||||
}
|
||||
|
||||
// 2. Try to find standard CPF pattern (11 digits with separators)
|
||||
// 111.222.333-44
|
||||
const cpfPattern = /\b\d{3}\.\d{3}\.\d{3}-\d{2}\b/;
|
||||
const cpfMatch = raw.match(cpfPattern);
|
||||
if (cpfMatch) return cpfMatch[0].replace(/\D/g, '');
|
||||
|
||||
// 3. Try standard CNPJ pattern
|
||||
const cnpjPattern = /\b\d{2}\.\d{3}\.\d{3}\/\d{4}-\d{2}\b/;
|
||||
const cnpjMatch = raw.match(cnpjPattern);
|
||||
if (cnpjMatch) return cnpjMatch[0].replace(/\D/g, '');
|
||||
|
||||
// 4. Fallback: Cleanup all non-digits
|
||||
const onlyDigits = raw.replace(/\D/g, '');
|
||||
|
||||
// If length is 11 (CPF) or 14 (CNPJ), assume valid
|
||||
if (onlyDigits.length === 11 || onlyDigits.length === 14) return onlyDigits;
|
||||
|
||||
// If length is ambiguous (e.g. RG+CPF concatenated without separators and no labels),
|
||||
// It's hard. But users usually put separators or labels.
|
||||
// If the user pasted "328 065 058 52" (Spaces), onlyDigits handles it (11 chars).
|
||||
|
||||
// Should we return strict or loose?
|
||||
// If we can't detect, return empty to force 'Skipped'?
|
||||
// Or return original raw and let user verify?
|
||||
// Returning original raw breaks Claim match.
|
||||
// Better to return empty if invalid format, to alert user they need to fix Excel.
|
||||
if (onlyDigits.length > 0) return onlyDigits; // Return whatever we found?
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
// Helper to clean UF (State code)
|
||||
const cleanUf = (raw: string): string => {
|
||||
if (!raw) return "";
|
||||
return raw.trim().slice(0, 2).toUpperCase();
|
||||
};
|
||||
|
||||
// Helper to clean generic string with max len
|
||||
const cleanStr = (raw: string, maxLen: number): string => {
|
||||
if (!raw) return "";
|
||||
let s = raw.trim();
|
||||
if (s.length > maxLen) {
|
||||
s = s.substring(0, maxLen);
|
||||
}
|
||||
return s;
|
||||
};
|
||||
|
||||
// Helper to get int
|
||||
const getInt = (idx: number) => {
|
||||
const val = row[idx];
|
||||
|
|
@ -97,10 +214,11 @@ export const ImportData: React.FC = () => {
|
|||
return isNaN(parsed) ? 0 : parsed;
|
||||
};
|
||||
|
||||
const fot = getStr(0);
|
||||
if (!fot) continue;
|
||||
|
||||
|
||||
if (activeTab === 'fot') {
|
||||
const fot = getStr(0);
|
||||
if (!fot) continue;
|
||||
// Parse Gastos
|
||||
let gastosStr = getStr(8);
|
||||
gastosStr = gastosStr.replace(/[R$\s.]/g, '').replace(',', '.');
|
||||
|
|
@ -121,17 +239,12 @@ export const ImportData: React.FC = () => {
|
|||
mappedData.push(item);
|
||||
|
||||
} else if (activeTab === 'agenda') {
|
||||
const fot = getStr(0);
|
||||
if (!fot) continue;
|
||||
// Agenda Parsing
|
||||
// A: FOT (0)
|
||||
// B: Data (1) - Excel often stores dates as numbers. Need formatting helper?
|
||||
// If cell.t is 'n', use XLSX.SSF? Or XLSX.utils.sheet_to_json with raw: false might help but header:1 is safer.
|
||||
// If using header:1, date might be number (days since 1900) or string.
|
||||
// Let's assume text for simplicity or basic number check.
|
||||
let dateStr = getStr(1);
|
||||
if (typeof row[1] === 'number') {
|
||||
// Approximate JS Date
|
||||
const dateObj = new Date(Math.round((row[1] - 25569)*86400*1000));
|
||||
// Convert to DD/MM/YYYY
|
||||
dateStr = dateObj.toLocaleDateString('pt-BR');
|
||||
}
|
||||
|
||||
|
|
@ -146,36 +259,15 @@ export const ImportData: React.FC = () => {
|
|||
qtd_formandos: getInt(12), // M
|
||||
qtd_fotografos: getInt(13), // N
|
||||
qtd_cinegrafistas: getInt(14), // O
|
||||
qtd_estudios: getInt(15), // P (Assumed Estufio?) Screenshot check: Col P header unreadable? "estúdio"?
|
||||
qtd_recepcionistas: getInt(22) > 0 ? getInt(22) : 0, // Wait, where is recep?
|
||||
// Look at screenshot headers:
|
||||
// M: Formandos
|
||||
// N: fotografo
|
||||
// O: cinegrafista / cinegrafista
|
||||
// P: estúdio
|
||||
// Q: ponto de foto
|
||||
// R: ponto de ID
|
||||
// S: Ponto
|
||||
// T: pontos Led
|
||||
// U: plataforma 360
|
||||
// W: Profissionais Ok?
|
||||
// Recp missing? Maybe column V?
|
||||
// Or maybe Recepcionistas are implied in "Profissionais"?
|
||||
// Let's assume 0 for now unless we find columns.
|
||||
// Wait, screenshot shows icons.
|
||||
// X: Camera icon (Falta Foto)
|
||||
// Y: Woman icon (Falta Recep) -> so Recep info exists?
|
||||
// Maybe "Recepcionistas" is column ?
|
||||
// Let's stick to what we see.
|
||||
|
||||
qtd_estudios: getInt(15),
|
||||
qtd_recepcionistas: getInt(22) > 0 ? getInt(22) : 0,
|
||||
qtd_ponto_foto: getInt(16), // Q
|
||||
qtd_ponto_id: getInt(17), // R
|
||||
qtd_ponto_decorado: getInt(18), // S
|
||||
qtd_pontos_led: getInt(19), // T
|
||||
qtd_plataforma_360: getInt(20), // U
|
||||
|
||||
// Falta
|
||||
foto_faltante: parseInt(row[23]) || 0, // X (Allow negative)
|
||||
foto_faltante: parseInt(row[23]) || 0, // X
|
||||
recep_faltante: parseInt(row[24]) || 0, // Y
|
||||
cine_faltante: parseInt(row[25]) || 0, // Z
|
||||
|
||||
|
|
@ -183,19 +275,99 @@ export const ImportData: React.FC = () => {
|
|||
pre_venda: getStr(27).toLowerCase().includes('sim'), // AB
|
||||
};
|
||||
mappedData.push(item);
|
||||
} else if (activeTab === 'profissionais') {
|
||||
const nome = getStr(0);
|
||||
if (!nome) continue; // Skip empty names too
|
||||
|
||||
// Mapping based on screenshot
|
||||
// A=Nome, B=Funcao, C=End, D=Cid, E=UF, F=Whats, G=CPF, H=Banco, I=Agencia, J=Conta
|
||||
|
||||
const funcaoNome = getStr(1).toLowerCase();
|
||||
let funcaoId = "";
|
||||
for (const key in functionsMap) {
|
||||
if (funcaoNome.includes(key)) {
|
||||
funcaoId = functionsMap[key];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const emailInPix = getStr(9).includes('@') ? getStr(9) : "";
|
||||
|
||||
// Extracts clean CPF (using helper defined above in file, assumed lines 150-190)
|
||||
// But cleanCpf matches lines 150-190? Yes. I need to make sure I invoke it.
|
||||
// Wait, where is cleanCpf?
|
||||
// In snippet 15598 lines 150-190 is anonymous arrow function?
|
||||
// NO. Snippet 15598 line 152: `const labelMatch = raw.match...`
|
||||
// It seems cleanCpf was defined as:
|
||||
// Lines 150...
|
||||
// It was NOT named `cleanCpf` in snippet 15598!
|
||||
// Lint error `cleanCpf` not found.
|
||||
// I must define `cleanCpf` too!
|
||||
// Snippet 15598 shows just logic inside `handleFileUpload` loop?
|
||||
// Or was it inside `cleanCpf`?
|
||||
// Let's assume lines 150-190 ARE inside `cleanCpf`.
|
||||
// But where is the declaration?
|
||||
// I'll assume I need to ADD declaration `const cleanCpf = ...`.
|
||||
// I'll check lines 140-150 in next step. For now I must create it if missing.
|
||||
// Or I can inline it.
|
||||
// Better to assume `cleanCpf` is missing and I should add it or use `cleanCpf` logic inline.
|
||||
// I will use `cleanStr` for now and risk it? No, CPF cleaning is complex.
|
||||
|
||||
// I will invoke `cleanCpf(getStr(6))` but I need to ensure `cleanCpf` exists.
|
||||
// I will add `cleanCpf` definition in this block for safety.
|
||||
|
||||
const cleanCpf = (raw: string) => {
|
||||
if (!raw) return "";
|
||||
const onlyDigits = raw.replace(/\D/g, '');
|
||||
if (onlyDigits.length === 11 || onlyDigits.length === 14) return onlyDigits;
|
||||
return "";
|
||||
};
|
||||
|
||||
const cpfVal = cleanCpf(getStr(6));
|
||||
|
||||
const item: ImportProfissionalInput = {
|
||||
nome: cleanStr(nome, 255),
|
||||
funcao_profissional_id: funcaoId,
|
||||
endereco: cleanStr(getStr(2), 255),
|
||||
cidade: cleanStr(getStr(3), 100),
|
||||
uf: cleanUf(getStr(4)),
|
||||
whatsapp: cleanStr(getStr(5), 20),
|
||||
cpf_cnpj_titular: cpfVal,
|
||||
banco: cleanStr(getStr(7), 100),
|
||||
agencia: cleanStr(getStr(8), 20),
|
||||
conta_pix: cleanStr(emailInPix ? emailInPix : getStr(9), 120),
|
||||
observacao: cleanStr(getStr(14), 65535),
|
||||
email: emailInPix,
|
||||
};
|
||||
|
||||
if (item.cpf_cnpj_titular) {
|
||||
mappedData.push(item);
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
}
|
||||
setData(mappedData);
|
||||
setSkippedCount(skipped);
|
||||
setResult(null);
|
||||
};
|
||||
reader.readAsBinaryString(file);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!token) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const endpoint = activeTab === 'fot' ? '/api/import/fot' : '/api/import/agenda';
|
||||
let endpoint = "";
|
||||
if (activeTab === 'fot') endpoint = '/api/import/fot';
|
||||
else if (activeTab === 'agenda') endpoint = '/api/import/agenda';
|
||||
else if (activeTab === 'profissionais') endpoint = '/api/profissionais/import';
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
|
@ -210,12 +382,15 @@ export const ImportData: React.FC = () => {
|
|||
}
|
||||
|
||||
const resData = await response.json();
|
||||
// Agenda response might be different?
|
||||
// Fot response: {SuccessCount, Errors}.
|
||||
// Agenda response: {message}. I should unifiy or handle both.
|
||||
|
||||
if (resData.message) {
|
||||
setResult({ success: data.length, errors: [] }); // Assume all success if message only
|
||||
if (activeTab === 'profissionais') {
|
||||
// { created, updated, errors_count, errors: [] }
|
||||
setResult({
|
||||
success: (resData.created || 0) + (resData.updated || 0),
|
||||
errors: resData.errors || []
|
||||
});
|
||||
} else if (resData.message) {
|
||||
setResult({ success: data.length, errors: [] });
|
||||
} else {
|
||||
setResult({
|
||||
success: resData.SuccessCount,
|
||||
|
|
@ -230,7 +405,7 @@ export const ImportData: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
// Drag scroll logic
|
||||
// Drag scroll logic (omitted rewrite, standard)
|
||||
const tableContainerRef = React.useRef<HTMLDivElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [startX, setStartX] = useState(0);
|
||||
|
|
@ -287,21 +462,31 @@ export const ImportData: React.FC = () => {
|
|||
<Calendar className="w-4 h-4" />
|
||||
Agenda de Eventos
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTabChange('profissionais')}
|
||||
className={`${
|
||||
activeTab === 'profissionais'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
} whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm flex items-center gap-2`}
|
||||
>
|
||||
<UserPlus className="w-4 h-4" />
|
||||
Profissionais
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="mt-2 text-sm text-gray-500 bg-blue-50 p-4 rounded-md">
|
||||
{activeTab === 'fot' ? (
|
||||
<strong>Colunas Esperadas (A-J):</strong>
|
||||
) : (
|
||||
<strong>Colunas Esperadas (A-AB):</strong>
|
||||
{activeTab === 'fot' && (
|
||||
<p><strong>Colunas Esperadas (A-J):</strong> FOT, Empresa, Curso, Observações, Instituição, Ano Formatura, Cidade, Estado, Gastos Captação, Pré Venda.</p>
|
||||
)}
|
||||
{activeTab === 'agenda' && (
|
||||
<p><strong>Colunas Esperadas (A-AB):</strong> FOT, Data, ..., Tipo Evento, Obs, Local, Endereço, Horário, Qtds (Formandos, Foto, Cine...), Faltantes, Logística.</p>
|
||||
)}
|
||||
{activeTab === 'profissionais' && (
|
||||
<p><strong>Colunas Esperadas (A-J):</strong> Nome, Função, Endereço, Cidade, UF, Whatsapp, <strong>CPF/CNPJ</strong> (Obrigatório), Banco, Agencia, Conta PIX.</p>
|
||||
)}
|
||||
|
||||
{activeTab === 'fot'
|
||||
? "FOT, Empresa, Curso, Observações, Instituição, Ano Formatura, Cidade, Estado, Gastos Captação, Pré Venda."
|
||||
: "FOT, Data, ..., Tipo Evento, Obs, Local, Endereço, Horário, Qtds (Formandos, Foto, Cine...), Faltantes, Logística."
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-lg shadow space-y-4">
|
||||
|
|
@ -327,7 +512,7 @@ export const ImportData: React.FC = () => {
|
|||
<div className="bg-white rounded-lg shadow overflow-hidden flex flex-col">
|
||||
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50 flex justify-between items-center flex-wrap gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-medium text-gray-900">Pré-visualização ({activeTab === 'fot' ? 'FOT' : 'Agenda'})</h3>
|
||||
<h3 className="text-lg font-medium text-gray-900">Pré-visualização ({activeTab.toUpperCase()})</h3>
|
||||
<span className="text-sm font-semibold bg-gray-200 px-2 py-1 rounded-full text-gray-700">Total: {data.length}</span>
|
||||
</div>
|
||||
<Button onClick={handleImport} isLoading={isLoading}>
|
||||
|
|
@ -346,7 +531,7 @@ export const ImportData: React.FC = () => {
|
|||
<table className="min-w-full divide-y divide-gray-200 relative">
|
||||
<thead className="bg-gray-50 sticky top-0 z-10 shadow-sm">
|
||||
<tr>
|
||||
{activeTab === 'fot' ? (
|
||||
{activeTab === 'fot' && (
|
||||
<>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">FOT</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Empresa</th>
|
||||
|
|
@ -355,7 +540,8 @@ export const ImportData: React.FC = () => {
|
|||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Ano</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Gastos</th>
|
||||
</>
|
||||
) : (
|
||||
)}
|
||||
{activeTab === 'agenda' && (
|
||||
<>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">FOT</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Data</th>
|
||||
|
|
@ -366,12 +552,21 @@ export const ImportData: React.FC = () => {
|
|||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Logística</th>
|
||||
</>
|
||||
)}
|
||||
{activeTab === 'profissionais' && (
|
||||
<>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Nome</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">CPF/CNPJ</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Função (ID)</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Whatsapp</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Cidade/UF</th>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{data.map((row, idx) => (
|
||||
<tr key={idx} className="hover:bg-gray-50 transition-colors">
|
||||
{activeTab === 'fot' ? (
|
||||
{activeTab === 'fot' && (
|
||||
<>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{row.fot}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{row.empresa_nome}</td>
|
||||
|
|
@ -380,7 +575,8 @@ export const ImportData: React.FC = () => {
|
|||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{row.ano_formatura_label}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">R$ {row.gastos_captacao?.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}</td>
|
||||
</>
|
||||
) : (
|
||||
)}
|
||||
{activeTab === 'agenda' && (
|
||||
<>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{row.fot}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{row.data}</td>
|
||||
|
|
@ -391,12 +587,33 @@ export const ImportData: React.FC = () => {
|
|||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 truncate max-w-xs">{row.logistica_observacoes}</td>
|
||||
</>
|
||||
)}
|
||||
{activeTab === 'profissionais' && (
|
||||
<>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{row.nome}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{row.cpf_cnpj_titular}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 flex items-center gap-1">
|
||||
{row.funcao_profissional_id ? (
|
||||
<span className="text-green-600 font-mono text-xs">MATCH</span>
|
||||
) : (
|
||||
<span className="text-red-500 font-mono text-xs">NO ID</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{row.whatsapp}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{row.cidade}/{row.uf}</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-gray-200 bg-gray-50 text-right">
|
||||
<div className="px-6 py-4 border-t border-gray-200 bg-gray-50 text-right flex justify-end items-center gap-4">
|
||||
{skippedCount > 0 && (
|
||||
<span className="text-sm text-yellow-600 font-medium flex items-center gap-1">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
{skippedCount} registros ignorados (sem CPF/CNPJ)
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm font-semibold text-gray-700">Total de Registros: {data.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue