From 175ee98f2afe995471820fa2ed7bf9bfd1e76a00 Mon Sep 17 00:00:00 2001 From: NANDO9322 Date: Fri, 16 Jan 2026 12:56:40 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20notifica=C3=A7=C3=B5es=20whatsapp=20com?= =?UTF-8?q?=20log=C3=ADstica=20e=20corre=C3=A7=C3=A3o=20de=20contagem=20de?= =?UTF-8?q?=20equipe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implementa envio de notificação WhatsApp ao aprovar evento ("Confirmado"), incluindo detalhes de logística (carro, motorista, passageiros) e endereço formatado. - Adiciona coluna `funcao_id` em `agenda_profissionais` para distinguir a função específica do profissional no evento. - Corrige bug de contagem duplicada na tabela de eventos para profissionais com múltiplas funções. - Corrige validação ao aceitar convite para checar lotação apenas da função designada. - Adiciona exibição da função (ex: Fotógrafo, Cinegrafista) na lista lateral do painel. --- backend/cmd/api/main.go | 7 +- backend/internal/agenda/handler.go | 12 +- backend/internal/agenda/service.go | 263 +++++++++++++++++- backend/internal/config/config.go | 2 + backend/internal/db/generated/agenda.sql.go | 22 +- .../internal/db/generated/logistica.sql.go | 36 +++ backend/internal/db/generated/models.go | 1 + .../003_add_funcao_agenda_profissionais.sql | 1 + backend/internal/db/queries/agenda.sql | 13 +- backend/internal/db/queries/logistica.sql | 6 + backend/internal/db/schema.sql | 9 + backend/internal/logistica/service.go | 82 +++++- backend/internal/notification/service.go | 85 ++++++ frontend/App.tsx | 4 +- frontend/components/EventTable.tsx | 71 +++-- frontend/contexts/DataContext.tsx | 11 +- frontend/pages/Dashboard.tsx | 137 +++++++-- frontend/pages/EventDetails.tsx | 2 +- frontend/pages/Login.tsx | 8 +- frontend/services/apiService.ts | 9 +- frontend/types.ts | 1 + 21 files changed, 699 insertions(+), 83 deletions(-) create mode 100644 backend/internal/db/migrations/003_add_funcao_agenda_profissionais.sql create mode 100644 backend/internal/notification/service.go diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index b104eaa..c2f1965 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -18,6 +18,7 @@ import ( "photum-backend/internal/escalas" "photum-backend/internal/finance" "photum-backend/internal/funcoes" + "photum-backend/internal/notification" "photum-backend/internal/logistica" "photum-backend/internal/profissionais" @@ -67,6 +68,8 @@ func main() { db.Migrate(pool) // Initialize services + // Initialize services + notificationService := notification.NewService() profissionaisService := profissionais.NewService(queries) authService := auth.NewService(queries, profissionaisService, cfg) funcoesService := funcoes.NewService(queries) @@ -76,7 +79,7 @@ func main() { tiposServicosService := tipos_servicos.NewService(queries) tiposEventosService := tipos_eventos.NewService(queries) cadastroFotService := cadastro_fot.NewService(queries) - agendaService := agenda.NewService(queries) + agendaService := agenda.NewService(queries, notificationService, cfg) availabilityService := availability.NewService(queries) s3Service := storage.NewS3Service(cfg) @@ -98,7 +101,7 @@ func main() { agendaHandler := agenda.NewHandler(agendaService) availabilityHandler := availability.NewHandler(availabilityService) escalasHandler := escalas.NewHandler(escalas.NewService(queries)) - logisticaHandler := logistica.NewHandler(logistica.NewService(queries)) + logisticaHandler := logistica.NewHandler(logistica.NewService(queries, notificationService, cfg)) codigosHandler := codigos.NewHandler(codigos.NewService(queries)) financeHandler := finance.NewHandler(finance.NewService(queries)) diff --git a/backend/internal/agenda/handler.go b/backend/internal/agenda/handler.go index 6ef5137..c392e30 100644 --- a/backend/internal/agenda/handler.go +++ b/backend/internal/agenda/handler.go @@ -179,7 +179,8 @@ func (h *Handler) AssignProfessional(c *gin.Context) { } var req struct { - ProfessionalID string `json:"professional_id" binding:"required"` + ProfessionalID string `json:"professional_id" binding:"required"` + FuncaoID *string `json:"funcao_id"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Dados inválidos: " + err.Error()}) @@ -192,7 +193,14 @@ func (h *Handler) AssignProfessional(c *gin.Context) { return } - if err := h.service.AssignProfessional(c.Request.Context(), agendaID, profID); err != nil { + var funcaoID *uuid.UUID + if req.FuncaoID != nil && *req.FuncaoID != "" { + if parsed, err := uuid.Parse(*req.FuncaoID); err == nil { + funcaoID = &parsed + } + } + + if err := h.service.AssignProfessional(c.Request.Context(), agendaID, profID, funcaoID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Erro ao atribuir profissional: " + err.Error()}) return } diff --git a/backend/internal/agenda/service.go b/backend/internal/agenda/service.go index 2fd8081..df1782d 100644 --- a/backend/internal/agenda/service.go +++ b/backend/internal/agenda/service.go @@ -4,21 +4,30 @@ import ( "context" "encoding/json" "fmt" + "log" "strconv" "time" + "photum-backend/internal/config" "photum-backend/internal/db/generated" + "photum-backend/internal/notification" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" ) type Service struct { - queries *generated.Queries + queries *generated.Queries + notification *notification.Service + cfg *config.Config } -func NewService(db *generated.Queries) *Service { - return &Service{queries: db} +func NewService(db *generated.Queries, notif *notification.Service, cfg *config.Config) *Service { + return &Service{ + queries: db, + notification: notif, + cfg: cfg, + } } type CreateAgendaRequest struct { @@ -236,12 +245,102 @@ func (s *Service) Delete(ctx context.Context, id uuid.UUID) error { return s.queries.DeleteAgenda(ctx, pgtype.UUID{Bytes: id, Valid: true}) } -func (s *Service) AssignProfessional(ctx context.Context, agendaID uuid.UUID, profID uuid.UUID) error { +func (s *Service) AssignProfessional(ctx context.Context, agendaID uuid.UUID, profID uuid.UUID, funcaoID *uuid.UUID) error { params := generated.AssignProfessionalParams{ AgendaID: pgtype.UUID{Bytes: agendaID, Valid: true}, ProfissionalID: pgtype.UUID{Bytes: profID, Valid: true}, } - return s.queries.AssignProfessional(ctx, params) + + if funcaoID != nil { + params.FuncaoID = pgtype.UUID{Bytes: *funcaoID, Valid: true} + } else { + params.FuncaoID = pgtype.UUID{Valid: false} + } + + if err := s.queries.AssignProfessional(ctx, params); err != nil { + return err + } + + // Notification Logic (Async or Sync - staying Sync but safe for now) + go func() { + // Create a detached context for the notification to avoid cancellation if the HTTP request ends + // Note: Ideally use a proper background context with timeout + bgCtx := context.Background() + + // 1. Get Agenda Details + agenda, err := s.queries.GetAgenda(bgCtx, pgtype.UUID{Bytes: agendaID, Valid: true}) + if err != nil { + log.Printf("[Notification] Erro ao buscar dados da agenda para notificação: %v", err) + return + } + + // 2. Get Professional Details + // We use GetProfissionalByID which returns the struct we need + prof, err := s.queries.GetProfissionalByID(bgCtx, pgtype.UUID{Bytes: profID, Valid: true}) + if err != nil { + log.Printf("[Notification] Erro ao buscar dados do profissional para notificação: %v", err) + return + } + + if prof.Whatsapp.String == "" { + log.Printf("[Notification] Profissional %s sem WhatsApp cadastrado. Ignorando.", prof.Nome) + return + } + + // 3. Get Event Type Details to show the name + // We need to fetch the TipoEvento name + var tipoEventoNome string = "Evento" + if agenda.TipoEventoID.Valid { + tipoEvento, err := s.queries.GetTipoEventoByID(bgCtx, pgtype.UUID{Bytes: agenda.TipoEventoID.Bytes, Valid: true}) + if err == nil { + tipoEventoNome = tipoEvento.Nome + } else { + log.Printf("[Notification] Erro ao buscar tipo de evento: %v", err) + } + } + + // 4. Format Message + dataEvento := "Data a definir" + if agenda.DataEvento.Valid { + dataEvento = agenda.DataEvento.Time.Format("02/01/2006") + } + + horario := "Horário a definir" + if agenda.Horario.Valid && agenda.Horario.String != "" { + horario = agenda.Horario.String + } + + local := "Local a definir" + if agenda.LocalEvento.Valid && agenda.LocalEvento.String != "" { + local = agenda.LocalEvento.String + } + + if agenda.Endereco.Valid && agenda.Endereco.String != "" { + local += fmt.Sprintf(" (%s)", agenda.Endereco.String) + } + + // Use configured FRONTEND_URL or default to localhost + baseUrl := "http://localhost:3000" + if s.cfg != nil && s.cfg.FrontendURL != "" { + baseUrl = s.cfg.FrontendURL + } + + // Refined Message: + msg := fmt.Sprintf("Olá *%s*! 📸\n\nVocê foi escalado(a) para um evento no dia *%s*.\n\n*Tipo:* %s\n*Horário:* %s\n*Local:* %s\n\nAcesse seu painel na Photum para conferir os detalhes e confirmar sua presença.\n%s/entrar", + prof.Nome, + dataEvento, + tipoEventoNome, + horario, + local, + baseUrl, + ) + + if err := s.notification.SendWhatsApp(prof.Whatsapp.String, msg); err != nil { + log.Printf("[Notification] Falha ao enviar WhatsApp para %s: %v", prof.Nome, err) + } + }() + + return nil } func (s *Service) RemoveProfessional(ctx context.Context, agendaID uuid.UUID, profID uuid.UUID) error { @@ -261,7 +360,159 @@ func (s *Service) UpdateStatus(ctx context.Context, agendaID uuid.UUID, status s ID: pgtype.UUID{Bytes: agendaID, Valid: true}, Status: pgtype.Text{String: status, Valid: true}, } - return s.queries.UpdateAgendaStatus(ctx, params) + + agenda, err := s.queries.UpdateAgendaStatus(ctx, params) + if err != nil { + return generated.Agenda{}, err + } + + // Se o evento for confirmado, enviar notificações com logística + if status == "Confirmado" { + go func() { + bgCtx := context.Background() + + // 1. Buscar Detalhes do Evento + tipoEventoNome := "Evento" + if agenda.TipoEventoID.Valid { + te, err := s.queries.GetTipoEventoByID(bgCtx, pgtype.UUID{Bytes: agenda.TipoEventoID.Bytes, Valid: true}) + if err == nil { + tipoEventoNome = te.Nome + } + } + + dataFmt := "Data a definir" + if agenda.DataEvento.Valid { + dataFmt = agenda.DataEvento.Time.Format("02/01/2006") + } + horaFmt := "Horário a definir" + if agenda.Horario.Valid { + horaFmt = agenda.Horario.String + } + localFmt := "" + if agenda.LocalEvento.Valid && agenda.LocalEvento.String != "" { + localFmt = agenda.LocalEvento.String + } + if agenda.Endereco.Valid && agenda.Endereco.String != "" { + if localFmt != "" { + localFmt += " - " + agenda.Endereco.String + } else { + localFmt = agenda.Endereco.String + } + } + if localFmt == "" { + localFmt = "Local a definir" + } + + // 2. Buscar Profissionais Escalados + profs, err := s.queries.GetAgendaProfessionals(bgCtx, pgtype.UUID{Bytes: agendaID, Valid: true}) + if err != nil { + log.Printf("[Notification] Erro ao buscar profissionais: %v", err) + return + } + + // 3. Buscar Logística (Carros) + carros, err := s.queries.ListCarrosByAgendaID(bgCtx, pgtype.UUID{Bytes: agendaID, Valid: true}) + if err != nil { + log.Printf("[Notification] Erro ao buscar carros: %v", err) + // Segue sem logística detalhada + } + + // Mapear Passageiros por Carro e Carro por Profissional + passengersByCar := make(map[uuid.UUID][]string) + carByProfessional := make(map[uuid.UUID]generated.ListCarrosByAgendaIDRow) // ProfID -> Carro + + for _, carro := range carros { + // Converter pgtype.UUID para uuid.UUID para usar como chave de mapa + carUuid := uuid.UUID(carro.ID.Bytes) + + passengers, err := s.queries.ListPassageirosByCarroID(bgCtx, carro.ID) + if err == nil { + var names []string + for _, p := range passengers { + names = append(names, p.Nome) + if p.ProfissionalID.Valid { + profUuid := uuid.UUID(p.ProfissionalID.Bytes) + carByProfessional[profUuid] = carro + } + } + passengersByCar[carUuid] = names + } + + // O motorista também está no carro + if carro.MotoristaID.Valid { + driverUuid := uuid.UUID(carro.MotoristaID.Bytes) + carByProfessional[driverUuid] = carro + } + } + + // 4. Enviar Mensagens + for _, p := range profs { + // O retorno de GetAgendaProfessionals traz p.*, então o ID é p.ID (cadastro_profissionais.id) + targetID := uuid.UUID(p.ID.Bytes) + + // Buscar dados completos para ter o whatsapp atualizado (se necessario, mas p.* ja tem whatsapp) + // Vamos usar p.Whatsapp direto se tiver. + phone := p.Whatsapp.String + if phone == "" { + // Tenta buscar novamente caso GetAgendaProfessionals não trouxer (mas traz p.*) + continue + } + + // Montar mensagem de logística + logisticaMsg := "" + if carro, ok := carByProfessional[targetID]; ok { + motorista := carro.NomeMotorista.String + if carro.MotoristaNomeSistema.Valid { + motorista = carro.MotoristaNomeSistema.String + } + + chegada := carro.HorarioChegada.String + + carroUuid := uuid.UUID(carro.ID.Bytes) + passageiros := passengersByCar[carroUuid] + + // Filtrar o próprio nome + var outrosPassageiros []string + for _, nome := range passageiros { + if nome != p.Nome { + outrosPassageiros = append(outrosPassageiros, nome) + } + } + + listaPassageiros := "" + if len(outrosPassageiros) > 0 { + listaPassageiros = "\nCom: " + for i, n := range outrosPassageiros { + if i > 0 { + listaPassageiros += ", " + } + listaPassageiros += n + } + } + + logisticaMsg = fmt.Sprintf("\n\n🚗 *Transporte Definido*\nCarro de: *%s*\nChegada: *%s*%s", motorista, chegada, listaPassageiros) + } else { + logisticaMsg = "\n\n🚗 *Transporte:* Verifique no painel ou entre em contato." + } + + msg := fmt.Sprintf("✅ *Evento Confirmado!* 🚀\n\nOlá *%s*! O evento a seguir foi confirmado e sua escala está valendo.\n\n📅 *%s*\n⏰ *%s*\n📍 *%s*\n📌 *%s*%s\n\nBom trabalho!", + p.Nome, + dataFmt, + horaFmt, + localFmt, + tipoEventoNome, + logisticaMsg, + ) + + if err := s.notification.SendWhatsApp(phone, msg); err != nil { + // Não logar erro para todos se for falha de validação de numero, mas logar warning + log.Printf("[Notification] Erro ao enviar para %s: %v", p.Nome, err) + } + } + }() + } + + return agenda, nil } func (s *Service) UpdateAssignmentStatus(ctx context.Context, agendaID, professionalID uuid.UUID, status string, reason string) error { diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 60aa2d6..54e329f 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -23,6 +23,7 @@ type Config struct { S3SecretKey string S3Bucket string S3Region string + FrontendURL string } func LoadConfig() *Config { @@ -46,6 +47,7 @@ func LoadConfig() *Config { S3SecretKey: getEnv("S3_SECRET_KEY", ""), S3Bucket: getEnv("S3_BUCKET", ""), S3Region: getEnv("S3_REGION", "nyc1"), + FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"), } } diff --git a/backend/internal/db/generated/agenda.sql.go b/backend/internal/db/generated/agenda.sql.go index 103c256..3f3acbc 100644 --- a/backend/internal/db/generated/agenda.sql.go +++ b/backend/internal/db/generated/agenda.sql.go @@ -12,18 +12,20 @@ import ( ) const assignProfessional = `-- name: AssignProfessional :exec -INSERT INTO agenda_profissionais (agenda_id, profissional_id) -VALUES ($1, $2) -ON CONFLICT (agenda_id, profissional_id) DO NOTHING +INSERT INTO agenda_profissionais (agenda_id, profissional_id, funcao_id) +VALUES ($1, $2, $3) +ON CONFLICT (agenda_id, profissional_id) DO UPDATE +SET funcao_id = EXCLUDED.funcao_id ` type AssignProfessionalParams struct { AgendaID pgtype.UUID `json:"agenda_id"` ProfissionalID pgtype.UUID `json:"profissional_id"` + FuncaoID pgtype.UUID `json:"funcao_id"` } func (q *Queries) AssignProfessional(ctx context.Context, arg AssignProfessionalParams) error { - _, err := q.db.Exec(ctx, assignProfessional, arg.AgendaID, arg.ProfissionalID) + _, err := q.db.Exec(ctx, assignProfessional, arg.AgendaID, arg.ProfissionalID, arg.FuncaoID) return err } @@ -349,7 +351,8 @@ SELECT (SELECT json_agg(json_build_object( 'professional_id', ap.profissional_id, 'status', ap.status, - 'motivo_rejeicao', ap.motivo_rejeicao + 'motivo_rejeicao', ap.motivo_rejeicao, + 'funcao_id', ap.funcao_id )) FROM agenda_profissionais ap WHERE ap.agenda_id = a.id), @@ -569,7 +572,8 @@ SELECT (SELECT json_agg(json_build_object( 'professional_id', ap.profissional_id, 'status', ap.status, - 'motivo_rejeicao', ap.motivo_rejeicao + 'motivo_rejeicao', ap.motivo_rejeicao, + 'funcao_id', ap.funcao_id )) FROM agenda_profissionais ap WHERE ap.agenda_id = a.id), @@ -979,7 +983,7 @@ const updateAssignmentPosition = `-- name: UpdateAssignmentPosition :one UPDATE agenda_profissionais SET posicao = $3 WHERE agenda_id = $1 AND profissional_id = $2 -RETURNING id, agenda_id, profissional_id, status, motivo_rejeicao, criado_em, posicao +RETURNING id, agenda_id, profissional_id, status, motivo_rejeicao, funcao_id, criado_em, posicao ` type UpdateAssignmentPositionParams struct { @@ -997,6 +1001,7 @@ func (q *Queries) UpdateAssignmentPosition(ctx context.Context, arg UpdateAssign &i.ProfissionalID, &i.Status, &i.MotivoRejeicao, + &i.FuncaoID, &i.CriadoEm, &i.Posicao, ) @@ -1007,7 +1012,7 @@ const updateAssignmentStatus = `-- name: UpdateAssignmentStatus :one UPDATE agenda_profissionais SET status = $3, motivo_rejeicao = $4 WHERE agenda_id = $1 AND profissional_id = $2 -RETURNING id, agenda_id, profissional_id, status, motivo_rejeicao, criado_em, posicao +RETURNING id, agenda_id, profissional_id, status, motivo_rejeicao, funcao_id, criado_em, posicao ` type UpdateAssignmentStatusParams struct { @@ -1031,6 +1036,7 @@ func (q *Queries) UpdateAssignmentStatus(ctx context.Context, arg UpdateAssignme &i.ProfissionalID, &i.Status, &i.MotivoRejeicao, + &i.FuncaoID, &i.CriadoEm, &i.Posicao, ) diff --git a/backend/internal/db/generated/logistica.sql.go b/backend/internal/db/generated/logistica.sql.go index f7822bc..73f9573 100644 --- a/backend/internal/db/generated/logistica.sql.go +++ b/backend/internal/db/generated/logistica.sql.go @@ -82,6 +82,42 @@ func (q *Queries) DeleteCarro(ctx context.Context, id pgtype.UUID) error { return err } +const getCarroByID = `-- name: GetCarroByID :one +SELECT c.id, c.agenda_id, c.motorista_id, c.nome_motorista, c.horario_chegada, c.observacoes, c.criado_em, c.atualizado_em, p.nome as motorista_nome_sistema +FROM logistica_carros c +LEFT JOIN cadastro_profissionais p ON c.motorista_id = p.id +WHERE c.id = $1 +` + +type GetCarroByIDRow struct { + ID pgtype.UUID `json:"id"` + AgendaID pgtype.UUID `json:"agenda_id"` + MotoristaID pgtype.UUID `json:"motorista_id"` + NomeMotorista pgtype.Text `json:"nome_motorista"` + HorarioChegada pgtype.Text `json:"horario_chegada"` + Observacoes pgtype.Text `json:"observacoes"` + CriadoEm pgtype.Timestamptz `json:"criado_em"` + AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"` + MotoristaNomeSistema pgtype.Text `json:"motorista_nome_sistema"` +} + +func (q *Queries) GetCarroByID(ctx context.Context, id pgtype.UUID) (GetCarroByIDRow, error) { + row := q.db.QueryRow(ctx, getCarroByID, id) + var i GetCarroByIDRow + err := row.Scan( + &i.ID, + &i.AgendaID, + &i.MotoristaID, + &i.NomeMotorista, + &i.HorarioChegada, + &i.Observacoes, + &i.CriadoEm, + &i.AtualizadoEm, + &i.MotoristaNomeSistema, + ) + return i, err +} + const listCarrosByAgendaID = `-- name: ListCarrosByAgendaID :many SELECT c.id, c.agenda_id, c.motorista_id, c.nome_motorista, c.horario_chegada, c.observacoes, c.criado_em, c.atualizado_em, p.nome as motorista_nome_sistema, p.avatar_url as motorista_avatar FROM logistica_carros c diff --git a/backend/internal/db/generated/models.go b/backend/internal/db/generated/models.go index e86a8e5..1dece9a 100644 --- a/backend/internal/db/generated/models.go +++ b/backend/internal/db/generated/models.go @@ -56,6 +56,7 @@ type AgendaProfissionai struct { ProfissionalID pgtype.UUID `json:"profissional_id"` Status pgtype.Text `json:"status"` MotivoRejeicao pgtype.Text `json:"motivo_rejeicao"` + FuncaoID pgtype.UUID `json:"funcao_id"` CriadoEm pgtype.Timestamptz `json:"criado_em"` Posicao pgtype.Text `json:"posicao"` } diff --git a/backend/internal/db/migrations/003_add_funcao_agenda_profissionais.sql b/backend/internal/db/migrations/003_add_funcao_agenda_profissionais.sql new file mode 100644 index 0000000..139eb2f --- /dev/null +++ b/backend/internal/db/migrations/003_add_funcao_agenda_profissionais.sql @@ -0,0 +1 @@ +ALTER TABLE agenda_profissionais ADD COLUMN funcao_id UUID REFERENCES funcoes_profissionais(id); diff --git a/backend/internal/db/queries/agenda.sql b/backend/internal/db/queries/agenda.sql index a837383..d53888f 100644 --- a/backend/internal/db/queries/agenda.sql +++ b/backend/internal/db/queries/agenda.sql @@ -47,7 +47,8 @@ SELECT (SELECT json_agg(json_build_object( 'professional_id', ap.profissional_id, 'status', ap.status, - 'motivo_rejeicao', ap.motivo_rejeicao + 'motivo_rejeicao', ap.motivo_rejeicao, + 'funcao_id', ap.funcao_id )) FROM agenda_profissionais ap WHERE ap.agenda_id = a.id), @@ -76,7 +77,8 @@ SELECT (SELECT json_agg(json_build_object( 'professional_id', ap.profissional_id, 'status', ap.status, - 'motivo_rejeicao', ap.motivo_rejeicao + 'motivo_rejeicao', ap.motivo_rejeicao, + 'funcao_id', ap.funcao_id )) FROM agenda_profissionais ap WHERE ap.agenda_id = a.id), @@ -126,9 +128,10 @@ DELETE FROM agenda WHERE id = $1; -- name: AssignProfessional :exec -INSERT INTO agenda_profissionais (agenda_id, profissional_id) -VALUES ($1, $2) -ON CONFLICT (agenda_id, profissional_id) DO NOTHING; +INSERT INTO agenda_profissionais (agenda_id, profissional_id, funcao_id) +VALUES ($1, $2, $3) +ON CONFLICT (agenda_id, profissional_id) DO UPDATE +SET funcao_id = EXCLUDED.funcao_id; -- name: RemoveProfessional :exec DELETE FROM agenda_profissionais diff --git a/backend/internal/db/queries/logistica.sql b/backend/internal/db/queries/logistica.sql index c8ce3e8..37cfb7a 100644 --- a/backend/internal/db/queries/logistica.sql +++ b/backend/internal/db/queries/logistica.sql @@ -13,6 +13,12 @@ LEFT JOIN cadastro_profissionais p ON c.motorista_id = p.id WHERE c.agenda_id = $1 ORDER BY c.criado_em; +-- name: GetCarroByID :one +SELECT c.*, p.nome as motorista_nome_sistema +FROM logistica_carros c +LEFT JOIN cadastro_profissionais p ON c.motorista_id = p.id +WHERE c.id = $1; + -- name: UpdateCarro :one UPDATE logistica_carros SET motorista_id = COALESCE($2, motorista_id), diff --git a/backend/internal/db/schema.sql b/backend/internal/db/schema.sql index 81d3d43..d8e8bd6 100644 --- a/backend/internal/db/schema.sql +++ b/backend/internal/db/schema.sql @@ -360,6 +360,7 @@ CREATE TABLE IF NOT EXISTS agenda_profissionais ( profissional_id UUID NOT NULL REFERENCES cadastro_profissionais(id) ON DELETE CASCADE, status VARCHAR(20) DEFAULT 'PENDENTE', -- PENDENTE, ACEITO, REJEITADO motivo_rejeicao TEXT, + funcao_id UUID REFERENCES funcoes_profissionais(id), criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(agenda_id, profissional_id) ); @@ -461,3 +462,11 @@ CREATE TABLE IF NOT EXISTS financial_transactions ( atualizado_em TIMESTAMPTZ NOT NULL DEFAULT NOW() ); +-- Migration to ensure funcao_id exists (Workaround for primitive migration system) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='agenda_profissionais' AND column_name='funcao_id') THEN + ALTER TABLE agenda_profissionais ADD COLUMN funcao_id UUID REFERENCES funcoes_profissionais(id); + END IF; +END $$; + diff --git a/backend/internal/logistica/service.go b/backend/internal/logistica/service.go index 8a2dd6f..811584a 100644 --- a/backend/internal/logistica/service.go +++ b/backend/internal/logistica/service.go @@ -2,19 +2,29 @@ package logistica import ( "context" + "fmt" + "log" + "photum-backend/internal/config" "photum-backend/internal/db/generated" + "photum-backend/internal/notification" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" ) type Service struct { - queries *generated.Queries + queries *generated.Queries + notification *notification.Service + cfg *config.Config } -func NewService(queries *generated.Queries) *Service { - return &Service{queries: queries} +func NewService(queries *generated.Queries, notification *notification.Service, cfg *config.Config) *Service { + return &Service{ + queries: queries, + notification: notification, + cfg: cfg, + } } type CreateCarroInput struct { @@ -131,7 +141,71 @@ func (s *Service) AddPassageiro(ctx context.Context, carroID, profissionalID str CarroID: pgtype.UUID{Bytes: cID, Valid: true}, ProfissionalID: pgtype.UUID{Bytes: pID, Valid: true}, }) - return err + if err != nil { + return err + } + + // Notification Logic + go func() { + bgCtx := context.Background() + + // 1. Get Car Details (Driver, Time, AgendaID) + carro, err := s.queries.GetCarroByID(bgCtx, pgtype.UUID{Bytes: cID, Valid: true}) + if err != nil { + log.Printf("[Logistica Notification] Erro ao buscar carro: %v", err) + return + } + + // 2. Get Agenda Details (for Location) + // We have agenda_id in carro, but need to fetch details + agendaVal, err := s.queries.GetAgenda(bgCtx, carro.AgendaID) + if err != nil { + log.Printf("[Logistica Notification] Erro ao buscar agenda: %v", err) + return + } + + // 3. Get Professional (Passenger) Details + prof, err := s.queries.GetProfissionalByID(bgCtx, pgtype.UUID{Bytes: pID, Valid: true}) + if err != nil { + log.Printf("[Logistica Notification] Erro ao buscar passageiro: %v", err) + return + } + + if prof.Whatsapp.String == "" { + return + } + + // 4. Format Message + motorista := "A definir" + if carro.NomeMotorista.Valid { + motorista = carro.NomeMotorista.String + } else if carro.MotoristaNomeSistema.Valid { + motorista = carro.MotoristaNomeSistema.String + } + + horarioSaida := "A combinar" + if carro.HorarioChegada.Valid { + horarioSaida = carro.HorarioChegada.String + } + + destino := "Local do Evento" + if agendaVal.LocalEvento.Valid { + destino = agendaVal.LocalEvento.String + } + + msg := fmt.Sprintf("Olá *%s*! 🚐\n\nVocê foi adicionado à logística de transporte.\n\n*Motorista:* %s\n*Saída:* %s\n*Destino:* %s\n\nAcesse seu painel para mais detalhes.", + prof.Nome, + motorista, + horarioSaida, + destino, + ) + + if err := s.notification.SendWhatsApp(prof.Whatsapp.String, msg); err != nil { + log.Printf("[Logistica Notification] Falha ao enviar: %v", err) + } + }() + + return nil } func (s *Service) RemovePassageiro(ctx context.Context, carroID, profissionalID string) error { diff --git a/backend/internal/notification/service.go b/backend/internal/notification/service.go new file mode 100644 index 0000000..4e1e5d4 --- /dev/null +++ b/backend/internal/notification/service.go @@ -0,0 +1,85 @@ +package notification + +import ( + "bytes" + "encoding/json" + "fmt" + "log" + "net/http" + "regexp" +) + +type Service struct { + apiURL string + apiKey string + instance string +} + +func NewService() *Service { + // Hardcoded configuration as per user request + return &Service{ + apiURL: "https://others-evolution-api.nsowe9.easypanel.host", + apiKey: "429683C4C977415CAAFCCE10F7D57E11", + instance: "NANDO", + } +} + +type MessageRequest struct { + Number string `json:"number"` + Text string `json:"text"` +} + +func (s *Service) SendWhatsApp(number string, message string) error { + cleanNumber := cleanPhoneNumber(number) + if cleanNumber == "" { + return fmt.Errorf("número de telefone inválido ou vazio") + } + + url := fmt.Sprintf("%s/message/sendText/%s", s.apiURL, s.instance) + + payload := MessageRequest{ + Number: cleanNumber, + Text: message, + } + + jsonData, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("erro ao criar payload JSON: %v", err) + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("erro ao criar requisição HTTP: %v", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("apikey", s.apiKey) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("erro ao enviar requisição para Evolution API: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return fmt.Errorf("erro na API da Evolution: Status Code %d", resp.StatusCode) + } + + log.Printf("WhatsApp enviado para %s com sucesso.", cleanNumber) + return nil +} + +func cleanPhoneNumber(phone string) string { + // Remove all non-numeric characters + re := regexp.MustCompile(`\D`) + clean := re.ReplaceAllString(phone, "") + + // Basic validation length (BR numbers usually 10 or 11 digits without DDI) + // If it doesn't have DDI (10 or 11 chars), add 55 + if len(clean) >= 10 && len(clean) <= 11 { + return "55" + clean + } + + return clean +} diff --git a/frontend/App.tsx b/frontend/App.tsx index 92fdaa5..f3e11cd 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -561,9 +561,7 @@ const AppContent: React.FC = () => { - - + } /> void; 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 } type SortField = @@ -36,6 +37,7 @@ export const EventTable: React.FC = ({ onAssignmentResponse, isManagingTeam = false, professionals = [], + functions = [], }) => { const canApprove = isManagingTeam && (userRole === UserRole.BUSINESS_OWNER || userRole === UserRole.SUPERADMIN); const canReject = userRole === UserRole.BUSINESS_OWNER || userRole === UserRole.SUPERADMIN; @@ -45,31 +47,50 @@ export const EventTable: React.FC = ({ const calculateTeamStatus = (event: EventData) => { const assignments = event.assignments || []; - // Helper to check if professional has a specific role - const hasRole = (professional: any, roleSlug: string) => { - if (!professional) return false; - const term = roleSlug.toLowerCase(); - - // Check functions array first (new multi-role system) - if (professional.functions && professional.functions.length > 0) { - return professional.functions.some((f: any) => f.nome.toLowerCase().includes(term)); - } - - // Fallback to legacy role field - return (professional.role || "").toLowerCase().includes(term); + // Helper to check if assignment handles a specific role + const isAssignedToRole = (assignment: any, roleSlug: string) => { + // If assignment has a specific function ID, check against that function's name + if (assignment.funcaoId && professionals && professionals.length > 0) { + // Find the function definition in the professionals list or a separate functions list? + // The plan said pass `functions` list. + // But checking `assignment.funcaoId` against `functions` list is cleaner. + // Let's assume we receive `functions` prop. + if (functions) { + const func = functions.find((f:any) => f.id === assignment.funcaoId); + if (func) { + return func.nome.toLowerCase().includes(roleSlug.toLowerCase()); + } + } + } + + // Fallback for legacy data or if assignment.funcaoId is missing (backward compatibility) + // Check the professional's capability (OLD BEHAVIOR - CAUSES DOUBLE COUNT if multi-role) + // ONLY fallback if funcaoId is missing. + if (!assignment.funcaoId) { + const professional = professionals.find(p => p.id === assignment.professionalId); + if (!professional) return false; + + // Check functions array first + if (professional.functions && professional.functions.length > 0) { + return professional.functions.some((f: any) => f.nome.toLowerCase().includes(roleSlug.toLowerCase())); + } + return (professional.role || "").toLowerCase().includes(roleSlug.toLowerCase()); + } + + return false; }; // Contadores de profissionais aceitos por tipo const acceptedFotografos = assignments.filter(a => - a.status === "ACEITO" && hasRole(professionals.find(p => p.id === a.professionalId), "fot") + a.status === "ACEITO" && isAssignedToRole(a, "fot") ).length; const acceptedRecepcionistas = assignments.filter(a => - a.status === "ACEITO" && hasRole(professionals.find(p => p.id === a.professionalId), "recep") + a.status === "ACEITO" && isAssignedToRole(a, "recep") ).length; const acceptedCinegrafistas = assignments.filter(a => - a.status === "ACEITO" && hasRole(professionals.find(p => p.id === a.professionalId), "cine") + a.status === "ACEITO" && isAssignedToRole(a, "cine") ).length; // Quantidades necessárias @@ -572,14 +593,28 @@ export const EventTable: React.FC = ({ >
{canApprove && event.status === EventStatus.PENDING_APPROVAL && ( +
+
)} {canReject && !canApprove && event.status === EventStatus.PENDING_APPROVAL && ( diff --git a/frontend/contexts/DataContext.tsx b/frontend/contexts/DataContext.tsx index 3c17562..856e16b 100644 --- a/frontend/contexts/DataContext.tsx +++ b/frontend/contexts/DataContext.tsx @@ -717,11 +717,12 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({ observacoes: e.observacoes_fot, typeId: e.tipo_evento_id, local_evento: e.local_evento, // Added local_evento mapping - assignments: Array.isArray(e.assigned_professionals) + assignments: Array.isArray(e.assigned_professionals) ? e.assigned_professionals.map((a: any) => ({ professionalId: a.professional_id, status: a.status, - rejectionReason: a.motivo_rejeicao + reason: a.motivo_rejeicao, + funcaoId: a.funcao_id })) : [], })); @@ -915,7 +916,7 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({ } }; - const assignPhotographer = async (eventId: string, photographerId: string) => { + const assignPhotographer = async (eventId: string, photographerId: string, funcaoId?: string) => { const token = localStorage.getItem('token'); const event = events.find(e => e.id === eventId); if (!event) return; @@ -928,7 +929,7 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({ if (isRemoving) { await apiRemoveProfessional(token, eventId, photographerId); } else { - await apiAssignProfessional(token, eventId, photographerId); + await apiAssignProfessional(token, eventId, photographerId, funcaoId); } } catch (error) { console.error("Failed to assign/remove professional", error); @@ -954,7 +955,7 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({ return { ...e, photographerIds: [...current, photographerId], - assignments: [...currentAssignments, { professionalId: photographerId, status: "PENDENTE" as any }] + assignments: [...currentAssignments, { professionalId: photographerId, status: "PENDENTE" as any, funcaoId }] }; } } diff --git a/frontend/pages/Dashboard.tsx b/frontend/pages/Dashboard.tsx index 03e5059..d08b90b 100644 --- a/frontend/pages/Dashboard.tsx +++ b/frontend/pages/Dashboard.tsx @@ -107,6 +107,7 @@ export const Dashboard: React.FC = ({ const [teamRoleFilter, setTeamRoleFilter] = useState("all"); const [teamStatusFilter, setTeamStatusFilter] = useState("all"); const [teamAvailabilityFilter, setTeamAvailabilityFilter] = useState("all"); + const [roleSelectionProf, setRoleSelectionProf] = useState(null); useEffect(() => { if (initialView) { @@ -125,10 +126,20 @@ export const Dashboard: React.FC = ({ // Contadores de profissionais aceitos por tipo // Helper to check if professional has a specific role - const hasRole = (professional: Professional | undefined, roleSlug: string) => { + const hasRole = (professional: Professional | undefined, roleSlug: string, assignedFuncaoId?: string) => { if (!professional) return false; const term = roleSlug.toLowerCase(); + // 1. If assignment has explicit function, check it + if (assignedFuncaoId) { + const fn = functions.find(f => f.id === assignedFuncaoId); + if (fn && fn.nome.toLowerCase().includes(term)) return true; + // If term didn't match the assigned function, return false (exclusive assignment) + // Unless we fallback? No, if assigned as Cine, shouldn't count as Fot. + if (fn) return false; + } + + // 2. Fallback to existing logic (capabilities) if no function assigned // Check functions array first (new multi-role system) if (professional.functions && professional.functions.length > 0) { return professional.functions.some(f => f.nome.toLowerCase().includes(term)); @@ -140,23 +151,23 @@ export const Dashboard: React.FC = ({ // Contadores de profissionais aceitos por tipo const acceptedFotografos = assignments.filter(a => - a.status === "ACEITO" && hasRole(professionals.find(p => p.id === a.professionalId), "fot") + a.status === "ACEITO" && hasRole(professionals.find(p => p.id === a.professionalId), "fot", a.funcaoId) ).length; const pendingFotografos = assignments.filter(a => - a.status === "PENDENTE" && hasRole(professionals.find(p => p.id === a.professionalId), "fot") + a.status === "PENDENTE" && hasRole(professionals.find(p => p.id === a.professionalId), "fot", a.funcaoId) ).length; const acceptedRecepcionistas = assignments.filter(a => - a.status === "ACEITO" && hasRole(professionals.find(p => p.id === a.professionalId), "recep") + a.status === "ACEITO" && hasRole(professionals.find(p => p.id === a.professionalId), "recep", a.funcaoId) ).length; const pendingRecepcionistas = assignments.filter(a => - a.status === "PENDENTE" && hasRole(professionals.find(p => p.id === a.professionalId), "recep") + a.status === "PENDENTE" && hasRole(professionals.find(p => p.id === a.professionalId), "recep", a.funcaoId) ).length; const acceptedCinegrafistas = assignments.filter(a => - a.status === "ACEITO" && hasRole(professionals.find(p => p.id === a.professionalId), "cine") + a.status === "ACEITO" && hasRole(professionals.find(p => p.id === a.professionalId), "cine", a.funcaoId) ).length; const pendingCinegrafistas = assignments.filter(a => @@ -368,30 +379,51 @@ export const Dashboard: React.FC = ({ return (professional.role || "").toLowerCase().includes(term); }; - const checkQuota = (roleSlugs: string[], acceptedCount: number, requiredCount: number, roleName: string) => { - // Verifica se o profissional tem essa função - const isProfessionalRole = roleSlugs.some(slug => hasRole(currentProfessional, slug)); - - if (isProfessionalRole) { - // Se já está cheio (ou excedido), bloqueia - if (acceptedCount >= requiredCount) { - return `A equipe de ${roleName} já está completa (${acceptedCount}/${requiredCount}).`; + // Helper to check if assignment handles a specific role (using ID first, then fallback) + const isAssignedToRole = (assignment: any, roleSlug: string) => { + if (assignment.funcaoId && functions) { + const func = functions.find(f => f.id === assignment.funcaoId); + if (func) return func.nome.toLowerCase().includes(roleSlug.toLowerCase()); } - } - return null; + // Fallback only if no function assigned + if (!assignment.funcaoId) { + const p = professionals.find(pr => pr.id === assignment.professionalId); + return hasRole(p, roleSlug); + } + return false; }; - // Contagens Atuais - const acceptedFot = assignments.filter(a => a.status === "ACEITO" && hasRole(professionals.find(p => p.id === a.professionalId), "fot")).length; - const acceptedRecep = assignments.filter(a => a.status === "ACEITO" && hasRole(professionals.find(p => p.id === a.professionalId), "recep")).length; - const acceptedCine = assignments.filter(a => a.status === "ACEITO" && hasRole(professionals.find(p => p.id === a.professionalId), "cine")).length; + // Contagens Atuais (Updated to match EventTable logic) + const acceptedFot = assignments.filter(a => a.status === "ACEITO" && isAssignedToRole(a, "fot")).length; + const acceptedRecep = assignments.filter(a => a.status === "ACEITO" && isAssignedToRole(a, "recep")).length; + const acceptedCine = assignments.filter(a => a.status === "ACEITO" && isAssignedToRole(a, "cine")).length; // Limites const reqFot = targetEvent.qtdFotografos || 0; const reqRecep = targetEvent.qtdRecepcionistas || 0; const reqCine = targetEvent.qtdCinegrafistas || 0; - // Verificações + // Find OUR assignment to know what we are accepting + const myAssignment = assignments.find(a => a.professionalId === currentProfessional.id); + + const checkQuota = (roleSlugs: string[], acceptedCount: number, requiredCount: number, roleName: string) => { + // Only check quota if I am assigned to this role + if (myAssignment) { + if (isAssignedToRole(myAssignment, roleSlugs[0])) { // Using first slug as proxy for role group + if (acceptedCount >= requiredCount) { + return `A equipe de ${roleName} já está completa (${acceptedCount}/${requiredCount}).`; + } + } + } else { + // Fallback if no assignment found (shouldn't happen for existing invite) + const isProfessionalRole = roleSlugs.some(slug => hasRole(currentProfessional, slug)); + if (isProfessionalRole && acceptedCount >= requiredCount) { + return `A equipe de ${roleName} já está completa (${acceptedCount}/${requiredCount}).`; + } + } + return null; + }; + const errors = []; const errFot = checkQuota(["fot"], acceptedFot, reqFot, "Fotografia"); @@ -435,8 +467,23 @@ export const Dashboard: React.FC = ({ const togglePhotographer = (photographerId: string) => { if (!selectedEvent) return; + const prof = professionals.find(p => p.id === photographerId); + + const assignment = selectedEvent.assignments?.find(a => a.professionalId === photographerId); + if (!assignment && prof && prof.functions && prof.functions.length > 1) { + setRoleSelectionProf(prof); + return; + } + assignPhotographer(selectedEvent.id, photographerId); }; + + const handleRoleSelect = (funcaoId: string) => { + if (roleSelectionProf && selectedEvent) { + assignPhotographer(selectedEvent.id, roleSelectionProf.id, funcaoId); + setRoleSelectionProf(null); + } + }; // --- RENDERS PER ROLE --- @@ -576,8 +623,9 @@ export const Dashboard: React.FC = ({ userRole={user.role} currentProfessionalId={currentProfessionalId} onAssignmentResponse={handleAssignmentResponse} - isManagingTeam={false} // Na gestão geral, não está gerenciando equipe + isManagingTeam={true} // Permitir aprovação na gestão geral professionals={professionals} // Adicionar lista de profissionais + functions={functions} />
)} @@ -1254,6 +1302,11 @@ export const Dashboard: React.FC = ({ {assignment.status === "PENDENTE" ? "Convite Pendente" : "Confirmado"} + {assignment.funcaoId && functions && ( + + {functions.find(f => f.id === assignment.funcaoId)?.nome || ""} + + )} @@ -1423,11 +1476,12 @@ export const Dashboard: React.FC = ({ const isAssigned = !!status && status !== "REJEITADO"; // Check if busy in other events on the same date - const isBusy = !isAssigned && events.some(e => + const busyEvent = !isAssigned ? events.find(e => e.id !== selectedEvent.id && e.date === selectedEvent.date && (e.assignments || []).some(a => a.professionalId === photographer.id && a.status === 'ACEITO') - ); + ) : undefined; + const isBusy = !!busyEvent; return ( = ({ )} {!status && ( - + {isBusy ? : } {isBusy ? "Em outro evento" : "Disponível"} @@ -1658,6 +1715,36 @@ export const Dashboard: React.FC = ({ onClose={() => setViewingProfessional(null)} /> )} + + {roleSelectionProf && ( +
+
+

Selecione a Função

+

+ Qual função {roleSelectionProf.nome} irá desempenhar neste evento? +

+
+ {roleSelectionProf.functions?.map((fn) => ( + + ))} +
+
+ +
+
+
+ )} ); diff --git a/frontend/pages/EventDetails.tsx b/frontend/pages/EventDetails.tsx index e43ee7e..03a51cb 100644 --- a/frontend/pages/EventDetails.tsx +++ b/frontend/pages/EventDetails.tsx @@ -81,7 +81,7 @@ const EventDetails: React.FC = () => {

Horário

-

{event.horario}

+

{event.horario || event.time || "Não definido"}

diff --git a/frontend/pages/Login.tsx b/frontend/pages/Login.tsx index d57811e..4e700a9 100644 --- a/frontend/pages/Login.tsx +++ b/frontend/pages/Login.tsx @@ -71,8 +71,12 @@ export const Login: React.FC = ({ onNavigate }) => { const handleProfessionalChoice = (isProfessional: boolean) => { setShowProfessionalPrompt(false); - setSelectedCadastroType(isProfessional ? 'professional' : 'client'); - setShowAccessCodeModal(true); + if (isProfessional) { + window.location.href = "/cadastro-profissional"; + } else { + setSelectedCadastroType('client'); + setShowAccessCodeModal(true); + } }; const handleLogin = async (e: React.FormEvent) => { diff --git a/frontend/services/apiService.ts b/frontend/services/apiService.ts index f5736bc..d0bf4b8 100644 --- a/frontend/services/apiService.ts +++ b/frontend/services/apiService.ts @@ -718,15 +718,20 @@ export async function rejectUser(userId: string, token: string): Promise> { +export async function assignProfessional(token: string, eventId: string, professionalId: string, funcaoId?: string): Promise> { try { + const body: any = { professional_id: professionalId }; + if (funcaoId) { + body.funcao_id = funcaoId; + } + const response = await fetch(`${API_BASE_URL}/api/agenda/${eventId}/professionals`, { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` }, - body: JSON.stringify({ professional_id: professionalId }) + body: JSON.stringify(body) }); if (!response.ok) { diff --git a/frontend/types.ts b/frontend/types.ts index fff1e45..51d5d83 100644 --- a/frontend/types.ts +++ b/frontend/types.ts @@ -108,6 +108,7 @@ export interface Assignment { professionalId: string; status: AssignmentStatus; reason?: string; + funcaoId?: string; } export interface EventData {