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:
NANDO9322 2026-02-02 16:15:16 -03:00
parent a6ba63203a
commit 542c8d4388
13 changed files with 1690 additions and 831 deletions

View file

@ -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)

View file

@ -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(

View file

@ -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;

View file

@ -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 $$;

View file

@ -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?"
})
}

View file

@ -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
}

View file

@ -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 />;
}

View file

@ -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>
);
};

View file

@ -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ê 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">

View 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>
);
};

View file

@ -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>
)}

View file

@ -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>
)}
&nbsp;
{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