Merge pull request #42 from rede5/Front-back-integracao-task18

feat: notificações whatsapp com logística e correção de contagem de equipe
This commit is contained in:
Andre F. Rodrigues 2026-01-16 12:57:30 -03:00 committed by GitHub
commit 8f081d20f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 699 additions and 83 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1 @@
ALTER TABLE agenda_profissionais ADD COLUMN funcao_id UUID REFERENCES funcoes_profissionais(id);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -561,9 +561,7 @@ const AppContent: React.FC = () => {
<Route
path="/cadastro-profissional"
element={
<AccessCodeProtectedRoute type="professional">
<ProfessionalRegisterWithRouter />
</AccessCodeProtectedRoute>
<ProfessionalRegisterWithRouter />
}
/>
<Route

View file

@ -13,6 +13,7 @@ interface EventTableProps {
onAssignmentResponse?: (e: React.MouseEvent, eventId: string, status: string, reason?: string) => 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<EventTableProps> = ({
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<EventTableProps> = ({
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<EventTableProps> = ({
>
<div className="flex items-center gap-2">
{canApprove && event.status === EventStatus.PENDING_APPROVAL && (
<div className="flex flex-col gap-1">
<button
onClick={(e) => onApprove?.(e, event.id)}
className="bg-green-500 text-white px-2 py-1 rounded text-xs font-semibold hover:bg-green-600 transition-colors flex items-center gap-1 whitespace-nowrap"
title="Aprovar evento"
onClick={(e) => {
const status = calculateTeamStatus(event);
if (!status.profissionaisOK) {
alert("A equipe deve estar completa para aprovar o evento.");
return;
}
onApprove?.(e, event.id);
}}
disabled={!calculateTeamStatus(event).profissionaisOK}
className={`px-2 py-1 rounded text-xs font-semibold flex items-center gap-1 whitespace-nowrap transition-colors ${
calculateTeamStatus(event).profissionaisOK
? "bg-green-500 text-white hover:bg-green-600"
: "bg-gray-300 text-gray-500 cursor-not-allowed"
}`}
title={!calculateTeamStatus(event).profissionaisOK ? "Equipe incompleta" : "Aprovar evento"}
>
<CheckCircle size={12} />
Aprovar
</button>
</div>
)}
{canReject && !canApprove && event.status === EventStatus.PENDING_APPROVAL && (

View file

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

View file

@ -107,6 +107,7 @@ export const Dashboard: React.FC<DashboardProps> = ({
const [teamRoleFilter, setTeamRoleFilter] = useState("all");
const [teamStatusFilter, setTeamStatusFilter] = useState("all");
const [teamAvailabilityFilter, setTeamAvailabilityFilter] = useState("all");
const [roleSelectionProf, setRoleSelectionProf] = useState<Professional | null>(null);
useEffect(() => {
if (initialView) {
@ -125,10 +126,20 @@ export const Dashboard: React.FC<DashboardProps> = ({
// 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<DashboardProps> = ({
// 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<DashboardProps> = ({
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<DashboardProps> = ({
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<DashboardProps> = ({
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}
/>
</div>
)}
@ -1254,6 +1302,11 @@ export const Dashboard: React.FC<DashboardProps> = ({
</span>
<span className="text-xs text-gray-500">
{assignment.status === "PENDENTE" ? "Convite Pendente" : "Confirmado"}
{assignment.funcaoId && functions && (
<span className="block text-xs font-semibold text-blue-600">
{functions.find(f => f.id === assignment.funcaoId)?.nome || ""}
</span>
)}
</span>
</div>
</div>
@ -1423,11 +1476,12 @@ export const Dashboard: React.FC<DashboardProps> = ({
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 (
<tr
@ -1493,7 +1547,10 @@ export const Dashboard: React.FC<DashboardProps> = ({
</span>
)}
{!status && (
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium ${isBusy ? "bg-red-100 text-red-800" : "bg-gray-100 text-gray-600"}`}>
<span
className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium ${isBusy ? "bg-red-100 text-red-800 cursor-help" : "bg-gray-100 text-gray-600"}`}
title={busyEvent ? `Em evento: ${busyEvent.name} - ${busyEvent.local_evento || 'Local não informado'} (${busyEvent.time || busyEvent.startTime || 'Horário indefinido'})` : ""}
>
{isBusy ? <UserX size={14} /> : <UserCheck size={14} />}
{isBusy ? "Em outro evento" : "Disponível"}
</span>
@ -1658,6 +1715,36 @@ export const Dashboard: React.FC<DashboardProps> = ({
onClose={() => setViewingProfessional(null)}
/>
)}
{roleSelectionProf && (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black bg-opacity-50">
<div className="bg-white rounded-lg p-6 w-96 max-w-full shadow-xl">
<h3 className="text-lg font-bold mb-4">Selecione a Função</h3>
<p className="mb-4 text-gray-600">
Qual função {roleSelectionProf.nome} irá desempenhar neste evento?
</p>
<div className="space-y-2">
{roleSelectionProf.functions?.map((fn) => (
<button
key={fn.id}
onClick={() => handleRoleSelect(fn.id)}
className="w-full text-left px-4 py-3 rounded border hover:bg-gray-50 flex items-center justify-between"
>
<span>{fn.nome}</span>
</button>
))}
</div>
<div className="mt-4 flex justify-end">
<button
onClick={() => setRoleSelectionProf(null)}
className="px-4 py-2 text-gray-600 hover:text-gray-800"
>
Cancelar
</button>
</div>
</div>
</div>
)}
</div>
</div>
);

View file

@ -81,7 +81,7 @@ const EventDetails: React.FC = () => {
<Clock className="w-5 h-5 text-brand-purple mt-1" />
<div>
<p className="text-xs text-gray-500 uppercase font-bold">Horário</p>
<p className="font-medium text-gray-800">{event.horario}</p>
<p className="font-medium text-gray-800">{event.horario || event.time || "Não definido"}</p>
</div>
</div>
<div className="flex items-start gap-3">

View file

@ -71,8 +71,12 @@ export const Login: React.FC<LoginProps> = ({ 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) => {

View file

@ -718,15 +718,20 @@ export async function rejectUser(userId: string, token: string): Promise<ApiResp
/**
* Atribui um profissional a um evento
*/
export async function assignProfessional(token: string, eventId: string, professionalId: string): Promise<ApiResponse<void>> {
export async function assignProfessional(token: string, eventId: string, professionalId: string, funcaoId?: string): Promise<ApiResponse<void>> {
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) {

View file

@ -108,6 +108,7 @@ export interface Assignment {
professionalId: string;
status: AssignmentStatus;
reason?: string;
funcaoId?: string;
}
export interface EventData {