feat: notificações whatsapp com logística e correção de contagem de equipe
- 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.
This commit is contained in:
parent
e78de535c1
commit
175ee98f2a
21 changed files with 699 additions and 83 deletions
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE agenda_profissionais ADD COLUMN funcao_id UUID REFERENCES funcoes_profissionais(id);
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 $$;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
85
backend/internal/notification/service.go
Normal file
85
backend/internal/notification/service.go
Normal 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
|
||||
}
|
||||
|
|
@ -561,9 +561,7 @@ const AppContent: React.FC = () => {
|
|||
<Route
|
||||
path="/cadastro-profissional"
|
||||
element={
|
||||
<AccessCodeProtectedRoute type="professional">
|
||||
<ProfessionalRegisterWithRouter />
|
||||
</AccessCodeProtectedRoute>
|
||||
<ProfessionalRegisterWithRouter />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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 }]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ export interface Assignment {
|
|||
professionalId: string;
|
||||
status: AssignmentStatus;
|
||||
reason?: string;
|
||||
funcaoId?: string;
|
||||
}
|
||||
|
||||
export interface EventData {
|
||||
|
|
|
|||
Loading…
Reference in a new issue