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/escalas"
|
||||||
"photum-backend/internal/finance"
|
"photum-backend/internal/finance"
|
||||||
"photum-backend/internal/funcoes"
|
"photum-backend/internal/funcoes"
|
||||||
|
"photum-backend/internal/notification"
|
||||||
|
|
||||||
"photum-backend/internal/logistica"
|
"photum-backend/internal/logistica"
|
||||||
"photum-backend/internal/profissionais"
|
"photum-backend/internal/profissionais"
|
||||||
|
|
@ -67,6 +68,8 @@ func main() {
|
||||||
db.Migrate(pool)
|
db.Migrate(pool)
|
||||||
|
|
||||||
// Initialize services
|
// Initialize services
|
||||||
|
// Initialize services
|
||||||
|
notificationService := notification.NewService()
|
||||||
profissionaisService := profissionais.NewService(queries)
|
profissionaisService := profissionais.NewService(queries)
|
||||||
authService := auth.NewService(queries, profissionaisService, cfg)
|
authService := auth.NewService(queries, profissionaisService, cfg)
|
||||||
funcoesService := funcoes.NewService(queries)
|
funcoesService := funcoes.NewService(queries)
|
||||||
|
|
@ -76,7 +79,7 @@ func main() {
|
||||||
tiposServicosService := tipos_servicos.NewService(queries)
|
tiposServicosService := tipos_servicos.NewService(queries)
|
||||||
tiposEventosService := tipos_eventos.NewService(queries)
|
tiposEventosService := tipos_eventos.NewService(queries)
|
||||||
cadastroFotService := cadastro_fot.NewService(queries)
|
cadastroFotService := cadastro_fot.NewService(queries)
|
||||||
agendaService := agenda.NewService(queries)
|
agendaService := agenda.NewService(queries, notificationService, cfg)
|
||||||
availabilityService := availability.NewService(queries)
|
availabilityService := availability.NewService(queries)
|
||||||
s3Service := storage.NewS3Service(cfg)
|
s3Service := storage.NewS3Service(cfg)
|
||||||
|
|
||||||
|
|
@ -98,7 +101,7 @@ func main() {
|
||||||
agendaHandler := agenda.NewHandler(agendaService)
|
agendaHandler := agenda.NewHandler(agendaService)
|
||||||
availabilityHandler := availability.NewHandler(availabilityService)
|
availabilityHandler := availability.NewHandler(availabilityService)
|
||||||
escalasHandler := escalas.NewHandler(escalas.NewService(queries))
|
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))
|
codigosHandler := codigos.NewHandler(codigos.NewService(queries))
|
||||||
financeHandler := finance.NewHandler(finance.NewService(queries))
|
financeHandler := finance.NewHandler(finance.NewService(queries))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -179,7 +179,8 @@ func (h *Handler) AssignProfessional(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
var req struct {
|
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 {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Dados inválidos: " + err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Dados inválidos: " + err.Error()})
|
||||||
|
|
@ -192,7 +193,14 @@ func (h *Handler) AssignProfessional(c *gin.Context) {
|
||||||
return
|
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()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erro ao atribuir profissional: " + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,21 +4,30 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"photum-backend/internal/config"
|
||||||
"photum-backend/internal/db/generated"
|
"photum-backend/internal/db/generated"
|
||||||
|
"photum-backend/internal/notification"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
queries *generated.Queries
|
queries *generated.Queries
|
||||||
|
notification *notification.Service
|
||||||
|
cfg *config.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(db *generated.Queries) *Service {
|
func NewService(db *generated.Queries, notif *notification.Service, cfg *config.Config) *Service {
|
||||||
return &Service{queries: db}
|
return &Service{
|
||||||
|
queries: db,
|
||||||
|
notification: notif,
|
||||||
|
cfg: cfg,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateAgendaRequest struct {
|
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})
|
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{
|
params := generated.AssignProfessionalParams{
|
||||||
AgendaID: pgtype.UUID{Bytes: agendaID, Valid: true},
|
AgendaID: pgtype.UUID{Bytes: agendaID, Valid: true},
|
||||||
ProfissionalID: pgtype.UUID{Bytes: profID, 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 {
|
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},
|
ID: pgtype.UUID{Bytes: agendaID, Valid: true},
|
||||||
Status: pgtype.Text{String: status, 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 {
|
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
|
S3SecretKey string
|
||||||
S3Bucket string
|
S3Bucket string
|
||||||
S3Region string
|
S3Region string
|
||||||
|
FrontendURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadConfig() *Config {
|
func LoadConfig() *Config {
|
||||||
|
|
@ -46,6 +47,7 @@ func LoadConfig() *Config {
|
||||||
S3SecretKey: getEnv("S3_SECRET_KEY", ""),
|
S3SecretKey: getEnv("S3_SECRET_KEY", ""),
|
||||||
S3Bucket: getEnv("S3_BUCKET", ""),
|
S3Bucket: getEnv("S3_BUCKET", ""),
|
||||||
S3Region: getEnv("S3_REGION", "nyc1"),
|
S3Region: getEnv("S3_REGION", "nyc1"),
|
||||||
|
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,18 +12,20 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
const assignProfessional = `-- name: AssignProfessional :exec
|
const assignProfessional = `-- name: AssignProfessional :exec
|
||||||
INSERT INTO agenda_profissionais (agenda_id, profissional_id)
|
INSERT INTO agenda_profissionais (agenda_id, profissional_id, funcao_id)
|
||||||
VALUES ($1, $2)
|
VALUES ($1, $2, $3)
|
||||||
ON CONFLICT (agenda_id, profissional_id) DO NOTHING
|
ON CONFLICT (agenda_id, profissional_id) DO UPDATE
|
||||||
|
SET funcao_id = EXCLUDED.funcao_id
|
||||||
`
|
`
|
||||||
|
|
||||||
type AssignProfessionalParams struct {
|
type AssignProfessionalParams struct {
|
||||||
AgendaID pgtype.UUID `json:"agenda_id"`
|
AgendaID pgtype.UUID `json:"agenda_id"`
|
||||||
ProfissionalID pgtype.UUID `json:"profissional_id"`
|
ProfissionalID pgtype.UUID `json:"profissional_id"`
|
||||||
|
FuncaoID pgtype.UUID `json:"funcao_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) AssignProfessional(ctx context.Context, arg AssignProfessionalParams) error {
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -349,7 +351,8 @@ SELECT
|
||||||
(SELECT json_agg(json_build_object(
|
(SELECT json_agg(json_build_object(
|
||||||
'professional_id', ap.profissional_id,
|
'professional_id', ap.profissional_id,
|
||||||
'status', ap.status,
|
'status', ap.status,
|
||||||
'motivo_rejeicao', ap.motivo_rejeicao
|
'motivo_rejeicao', ap.motivo_rejeicao,
|
||||||
|
'funcao_id', ap.funcao_id
|
||||||
))
|
))
|
||||||
FROM agenda_profissionais ap
|
FROM agenda_profissionais ap
|
||||||
WHERE ap.agenda_id = a.id),
|
WHERE ap.agenda_id = a.id),
|
||||||
|
|
@ -569,7 +572,8 @@ SELECT
|
||||||
(SELECT json_agg(json_build_object(
|
(SELECT json_agg(json_build_object(
|
||||||
'professional_id', ap.profissional_id,
|
'professional_id', ap.profissional_id,
|
||||||
'status', ap.status,
|
'status', ap.status,
|
||||||
'motivo_rejeicao', ap.motivo_rejeicao
|
'motivo_rejeicao', ap.motivo_rejeicao,
|
||||||
|
'funcao_id', ap.funcao_id
|
||||||
))
|
))
|
||||||
FROM agenda_profissionais ap
|
FROM agenda_profissionais ap
|
||||||
WHERE ap.agenda_id = a.id),
|
WHERE ap.agenda_id = a.id),
|
||||||
|
|
@ -979,7 +983,7 @@ const updateAssignmentPosition = `-- name: UpdateAssignmentPosition :one
|
||||||
UPDATE agenda_profissionais
|
UPDATE agenda_profissionais
|
||||||
SET posicao = $3
|
SET posicao = $3
|
||||||
WHERE agenda_id = $1 AND profissional_id = $2
|
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 {
|
type UpdateAssignmentPositionParams struct {
|
||||||
|
|
@ -997,6 +1001,7 @@ func (q *Queries) UpdateAssignmentPosition(ctx context.Context, arg UpdateAssign
|
||||||
&i.ProfissionalID,
|
&i.ProfissionalID,
|
||||||
&i.Status,
|
&i.Status,
|
||||||
&i.MotivoRejeicao,
|
&i.MotivoRejeicao,
|
||||||
|
&i.FuncaoID,
|
||||||
&i.CriadoEm,
|
&i.CriadoEm,
|
||||||
&i.Posicao,
|
&i.Posicao,
|
||||||
)
|
)
|
||||||
|
|
@ -1007,7 +1012,7 @@ const updateAssignmentStatus = `-- name: UpdateAssignmentStatus :one
|
||||||
UPDATE agenda_profissionais
|
UPDATE agenda_profissionais
|
||||||
SET status = $3, motivo_rejeicao = $4
|
SET status = $3, motivo_rejeicao = $4
|
||||||
WHERE agenda_id = $1 AND profissional_id = $2
|
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 {
|
type UpdateAssignmentStatusParams struct {
|
||||||
|
|
@ -1031,6 +1036,7 @@ func (q *Queries) UpdateAssignmentStatus(ctx context.Context, arg UpdateAssignme
|
||||||
&i.ProfissionalID,
|
&i.ProfissionalID,
|
||||||
&i.Status,
|
&i.Status,
|
||||||
&i.MotivoRejeicao,
|
&i.MotivoRejeicao,
|
||||||
|
&i.FuncaoID,
|
||||||
&i.CriadoEm,
|
&i.CriadoEm,
|
||||||
&i.Posicao,
|
&i.Posicao,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,42 @@ func (q *Queries) DeleteCarro(ctx context.Context, id pgtype.UUID) error {
|
||||||
return err
|
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
|
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
|
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
|
FROM logistica_carros c
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ type AgendaProfissionai struct {
|
||||||
ProfissionalID pgtype.UUID `json:"profissional_id"`
|
ProfissionalID pgtype.UUID `json:"profissional_id"`
|
||||||
Status pgtype.Text `json:"status"`
|
Status pgtype.Text `json:"status"`
|
||||||
MotivoRejeicao pgtype.Text `json:"motivo_rejeicao"`
|
MotivoRejeicao pgtype.Text `json:"motivo_rejeicao"`
|
||||||
|
FuncaoID pgtype.UUID `json:"funcao_id"`
|
||||||
CriadoEm pgtype.Timestamptz `json:"criado_em"`
|
CriadoEm pgtype.Timestamptz `json:"criado_em"`
|
||||||
Posicao pgtype.Text `json:"posicao"`
|
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(
|
(SELECT json_agg(json_build_object(
|
||||||
'professional_id', ap.profissional_id,
|
'professional_id', ap.profissional_id,
|
||||||
'status', ap.status,
|
'status', ap.status,
|
||||||
'motivo_rejeicao', ap.motivo_rejeicao
|
'motivo_rejeicao', ap.motivo_rejeicao,
|
||||||
|
'funcao_id', ap.funcao_id
|
||||||
))
|
))
|
||||||
FROM agenda_profissionais ap
|
FROM agenda_profissionais ap
|
||||||
WHERE ap.agenda_id = a.id),
|
WHERE ap.agenda_id = a.id),
|
||||||
|
|
@ -76,7 +77,8 @@ SELECT
|
||||||
(SELECT json_agg(json_build_object(
|
(SELECT json_agg(json_build_object(
|
||||||
'professional_id', ap.profissional_id,
|
'professional_id', ap.profissional_id,
|
||||||
'status', ap.status,
|
'status', ap.status,
|
||||||
'motivo_rejeicao', ap.motivo_rejeicao
|
'motivo_rejeicao', ap.motivo_rejeicao,
|
||||||
|
'funcao_id', ap.funcao_id
|
||||||
))
|
))
|
||||||
FROM agenda_profissionais ap
|
FROM agenda_profissionais ap
|
||||||
WHERE ap.agenda_id = a.id),
|
WHERE ap.agenda_id = a.id),
|
||||||
|
|
@ -126,9 +128,10 @@ DELETE FROM agenda
|
||||||
WHERE id = $1;
|
WHERE id = $1;
|
||||||
|
|
||||||
-- name: AssignProfessional :exec
|
-- name: AssignProfessional :exec
|
||||||
INSERT INTO agenda_profissionais (agenda_id, profissional_id)
|
INSERT INTO agenda_profissionais (agenda_id, profissional_id, funcao_id)
|
||||||
VALUES ($1, $2)
|
VALUES ($1, $2, $3)
|
||||||
ON CONFLICT (agenda_id, profissional_id) DO NOTHING;
|
ON CONFLICT (agenda_id, profissional_id) DO UPDATE
|
||||||
|
SET funcao_id = EXCLUDED.funcao_id;
|
||||||
|
|
||||||
-- name: RemoveProfessional :exec
|
-- name: RemoveProfessional :exec
|
||||||
DELETE FROM agenda_profissionais
|
DELETE FROM agenda_profissionais
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,12 @@ LEFT JOIN cadastro_profissionais p ON c.motorista_id = p.id
|
||||||
WHERE c.agenda_id = $1
|
WHERE c.agenda_id = $1
|
||||||
ORDER BY c.criado_em;
|
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
|
-- name: UpdateCarro :one
|
||||||
UPDATE logistica_carros
|
UPDATE logistica_carros
|
||||||
SET motorista_id = COALESCE($2, motorista_id),
|
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,
|
profissional_id UUID NOT NULL REFERENCES cadastro_profissionais(id) ON DELETE CASCADE,
|
||||||
status VARCHAR(20) DEFAULT 'PENDENTE', -- PENDENTE, ACEITO, REJEITADO
|
status VARCHAR(20) DEFAULT 'PENDENTE', -- PENDENTE, ACEITO, REJEITADO
|
||||||
motivo_rejeicao TEXT,
|
motivo_rejeicao TEXT,
|
||||||
|
funcao_id UUID REFERENCES funcoes_profissionais(id),
|
||||||
criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
UNIQUE(agenda_id, profissional_id)
|
UNIQUE(agenda_id, profissional_id)
|
||||||
);
|
);
|
||||||
|
|
@ -461,3 +462,11 @@ CREATE TABLE IF NOT EXISTS financial_transactions (
|
||||||
atualizado_em TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"photum-backend/internal/config"
|
||||||
"photum-backend/internal/db/generated"
|
"photum-backend/internal/db/generated"
|
||||||
|
"photum-backend/internal/notification"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
queries *generated.Queries
|
queries *generated.Queries
|
||||||
|
notification *notification.Service
|
||||||
|
cfg *config.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(queries *generated.Queries) *Service {
|
func NewService(queries *generated.Queries, notification *notification.Service, cfg *config.Config) *Service {
|
||||||
return &Service{queries: queries}
|
return &Service{
|
||||||
|
queries: queries,
|
||||||
|
notification: notification,
|
||||||
|
cfg: cfg,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateCarroInput struct {
|
type CreateCarroInput struct {
|
||||||
|
|
@ -131,7 +141,71 @@ func (s *Service) AddPassageiro(ctx context.Context, carroID, profissionalID str
|
||||||
CarroID: pgtype.UUID{Bytes: cID, Valid: true},
|
CarroID: pgtype.UUID{Bytes: cID, Valid: true},
|
||||||
ProfissionalID: pgtype.UUID{Bytes: pID, 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 {
|
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
|
<Route
|
||||||
path="/cadastro-profissional"
|
path="/cadastro-profissional"
|
||||||
element={
|
element={
|
||||||
<AccessCodeProtectedRoute type="professional">
|
<ProfessionalRegisterWithRouter />
|
||||||
<ProfessionalRegisterWithRouter />
|
|
||||||
</AccessCodeProtectedRoute>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ interface EventTableProps {
|
||||||
onAssignmentResponse?: (e: React.MouseEvent, eventId: string, status: string, reason?: string) => void;
|
onAssignmentResponse?: (e: React.MouseEvent, eventId: string, status: string, reason?: string) => void;
|
||||||
isManagingTeam?: boolean; // Nova prop para determinar se está na tela de gerenciar equipe
|
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
|
professionals?: any[]; // Lista de profissionais para cálculos de gestão de equipe
|
||||||
|
functions?: any[]; // Lista de funções disponíveis
|
||||||
}
|
}
|
||||||
|
|
||||||
type SortField =
|
type SortField =
|
||||||
|
|
@ -36,6 +37,7 @@ export const EventTable: React.FC<EventTableProps> = ({
|
||||||
onAssignmentResponse,
|
onAssignmentResponse,
|
||||||
isManagingTeam = false,
|
isManagingTeam = false,
|
||||||
professionals = [],
|
professionals = [],
|
||||||
|
functions = [],
|
||||||
}) => {
|
}) => {
|
||||||
const canApprove = isManagingTeam && (userRole === UserRole.BUSINESS_OWNER || userRole === UserRole.SUPERADMIN);
|
const canApprove = isManagingTeam && (userRole === UserRole.BUSINESS_OWNER || userRole === UserRole.SUPERADMIN);
|
||||||
const canReject = 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 calculateTeamStatus = (event: EventData) => {
|
||||||
const assignments = event.assignments || [];
|
const assignments = event.assignments || [];
|
||||||
|
|
||||||
// Helper to check if professional has a specific role
|
// Helper to check if assignment handles a specific role
|
||||||
const hasRole = (professional: any, roleSlug: string) => {
|
const isAssignedToRole = (assignment: any, roleSlug: string) => {
|
||||||
if (!professional) return false;
|
// If assignment has a specific function ID, check against that function's name
|
||||||
const term = roleSlug.toLowerCase();
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check functions array first (new multi-role system)
|
// Fallback for legacy data or if assignment.funcaoId is missing (backward compatibility)
|
||||||
if (professional.functions && professional.functions.length > 0) {
|
// Check the professional's capability (OLD BEHAVIOR - CAUSES DOUBLE COUNT if multi-role)
|
||||||
return professional.functions.some((f: any) => f.nome.toLowerCase().includes(term));
|
// ONLY fallback if funcaoId is missing.
|
||||||
}
|
if (!assignment.funcaoId) {
|
||||||
|
const professional = professionals.find(p => p.id === assignment.professionalId);
|
||||||
|
if (!professional) return false;
|
||||||
|
|
||||||
// Fallback to legacy role field
|
// Check functions array first
|
||||||
return (professional.role || "").toLowerCase().includes(term);
|
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
|
// Contadores de profissionais aceitos por tipo
|
||||||
const acceptedFotografos = assignments.filter(a =>
|
const acceptedFotografos = assignments.filter(a =>
|
||||||
a.status === "ACEITO" && hasRole(professionals.find(p => p.id === a.professionalId), "fot")
|
a.status === "ACEITO" && isAssignedToRole(a, "fot")
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
const acceptedRecepcionistas = assignments.filter(a =>
|
const acceptedRecepcionistas = assignments.filter(a =>
|
||||||
a.status === "ACEITO" && hasRole(professionals.find(p => p.id === a.professionalId), "recep")
|
a.status === "ACEITO" && isAssignedToRole(a, "recep")
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
const acceptedCinegrafistas = assignments.filter(a =>
|
const acceptedCinegrafistas = assignments.filter(a =>
|
||||||
a.status === "ACEITO" && hasRole(professionals.find(p => p.id === a.professionalId), "cine")
|
a.status === "ACEITO" && isAssignedToRole(a, "cine")
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
// Quantidades necessárias
|
// Quantidades necessárias
|
||||||
|
|
@ -572,14 +593,28 @@ export const EventTable: React.FC<EventTableProps> = ({
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{canApprove && event.status === EventStatus.PENDING_APPROVAL && (
|
{canApprove && event.status === EventStatus.PENDING_APPROVAL && (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={(e) => onApprove?.(e, event.id)}
|
onClick={(e) => {
|
||||||
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"
|
const status = calculateTeamStatus(event);
|
||||||
title="Aprovar evento"
|
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} />
|
<CheckCircle size={12} />
|
||||||
Aprovar
|
Aprovar
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canReject && !canApprove && event.status === EventStatus.PENDING_APPROVAL && (
|
{canReject && !canApprove && event.status === EventStatus.PENDING_APPROVAL && (
|
||||||
|
|
|
||||||
|
|
@ -717,11 +717,12 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({
|
||||||
observacoes: e.observacoes_fot,
|
observacoes: e.observacoes_fot,
|
||||||
typeId: e.tipo_evento_id,
|
typeId: e.tipo_evento_id,
|
||||||
local_evento: e.local_evento, // Added local_evento mapping
|
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) => ({
|
? e.assigned_professionals.map((a: any) => ({
|
||||||
professionalId: a.professional_id,
|
professionalId: a.professional_id,
|
||||||
status: a.status,
|
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 token = localStorage.getItem('token');
|
||||||
const event = events.find(e => e.id === eventId);
|
const event = events.find(e => e.id === eventId);
|
||||||
if (!event) return;
|
if (!event) return;
|
||||||
|
|
@ -928,7 +929,7 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({
|
||||||
if (isRemoving) {
|
if (isRemoving) {
|
||||||
await apiRemoveProfessional(token, eventId, photographerId);
|
await apiRemoveProfessional(token, eventId, photographerId);
|
||||||
} else {
|
} else {
|
||||||
await apiAssignProfessional(token, eventId, photographerId);
|
await apiAssignProfessional(token, eventId, photographerId, funcaoId);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to assign/remove professional", error);
|
console.error("Failed to assign/remove professional", error);
|
||||||
|
|
@ -954,7 +955,7 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({
|
||||||
return {
|
return {
|
||||||
...e,
|
...e,
|
||||||
photographerIds: [...current, photographerId],
|
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 [teamRoleFilter, setTeamRoleFilter] = useState("all");
|
||||||
const [teamStatusFilter, setTeamStatusFilter] = useState("all");
|
const [teamStatusFilter, setTeamStatusFilter] = useState("all");
|
||||||
const [teamAvailabilityFilter, setTeamAvailabilityFilter] = useState("all");
|
const [teamAvailabilityFilter, setTeamAvailabilityFilter] = useState("all");
|
||||||
|
const [roleSelectionProf, setRoleSelectionProf] = useState<Professional | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialView) {
|
if (initialView) {
|
||||||
|
|
@ -125,10 +126,20 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
||||||
|
|
||||||
// Contadores de profissionais aceitos por tipo
|
// Contadores de profissionais aceitos por tipo
|
||||||
// Helper to check if professional has a specific role
|
// 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;
|
if (!professional) return false;
|
||||||
const term = roleSlug.toLowerCase();
|
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)
|
// Check functions array first (new multi-role system)
|
||||||
if (professional.functions && professional.functions.length > 0) {
|
if (professional.functions && professional.functions.length > 0) {
|
||||||
return professional.functions.some(f => f.nome.toLowerCase().includes(term));
|
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
|
// Contadores de profissionais aceitos por tipo
|
||||||
const acceptedFotografos = assignments.filter(a =>
|
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;
|
).length;
|
||||||
|
|
||||||
const pendingFotografos = assignments.filter(a =>
|
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;
|
).length;
|
||||||
|
|
||||||
const acceptedRecepcionistas = assignments.filter(a =>
|
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;
|
).length;
|
||||||
|
|
||||||
const pendingRecepcionistas = assignments.filter(a =>
|
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;
|
).length;
|
||||||
|
|
||||||
const acceptedCinegrafistas = assignments.filter(a =>
|
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;
|
).length;
|
||||||
|
|
||||||
const pendingCinegrafistas = assignments.filter(a =>
|
const pendingCinegrafistas = assignments.filter(a =>
|
||||||
|
|
@ -368,30 +379,51 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
||||||
return (professional.role || "").toLowerCase().includes(term);
|
return (professional.role || "").toLowerCase().includes(term);
|
||||||
};
|
};
|
||||||
|
|
||||||
const checkQuota = (roleSlugs: string[], acceptedCount: number, requiredCount: number, roleName: string) => {
|
// Helper to check if assignment handles a specific role (using ID first, then fallback)
|
||||||
// Verifica se o profissional tem essa função
|
const isAssignedToRole = (assignment: any, roleSlug: string) => {
|
||||||
const isProfessionalRole = roleSlugs.some(slug => hasRole(currentProfessional, slug));
|
if (assignment.funcaoId && functions) {
|
||||||
|
const func = functions.find(f => f.id === assignment.funcaoId);
|
||||||
if (isProfessionalRole) {
|
if (func) return func.nome.toLowerCase().includes(roleSlug.toLowerCase());
|
||||||
// Se já está cheio (ou excedido), bloqueia
|
|
||||||
if (acceptedCount >= requiredCount) {
|
|
||||||
return `A equipe de ${roleName} já está completa (${acceptedCount}/${requiredCount}).`;
|
|
||||||
}
|
}
|
||||||
}
|
// Fallback only if no function assigned
|
||||||
return null;
|
if (!assignment.funcaoId) {
|
||||||
|
const p = professionals.find(pr => pr.id === assignment.professionalId);
|
||||||
|
return hasRole(p, roleSlug);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Contagens Atuais
|
// Contagens Atuais (Updated to match EventTable logic)
|
||||||
const acceptedFot = assignments.filter(a => a.status === "ACEITO" && hasRole(professionals.find(p => p.id === a.professionalId), "fot")).length;
|
const acceptedFot = assignments.filter(a => a.status === "ACEITO" && isAssignedToRole(a, "fot")).length;
|
||||||
const acceptedRecep = assignments.filter(a => a.status === "ACEITO" && hasRole(professionals.find(p => p.id === a.professionalId), "recep")).length;
|
const acceptedRecep = assignments.filter(a => a.status === "ACEITO" && isAssignedToRole(a, "recep")).length;
|
||||||
const acceptedCine = assignments.filter(a => a.status === "ACEITO" && hasRole(professionals.find(p => p.id === a.professionalId), "cine")).length;
|
const acceptedCine = assignments.filter(a => a.status === "ACEITO" && isAssignedToRole(a, "cine")).length;
|
||||||
|
|
||||||
// Limites
|
// Limites
|
||||||
const reqFot = targetEvent.qtdFotografos || 0;
|
const reqFot = targetEvent.qtdFotografos || 0;
|
||||||
const reqRecep = targetEvent.qtdRecepcionistas || 0;
|
const reqRecep = targetEvent.qtdRecepcionistas || 0;
|
||||||
const reqCine = targetEvent.qtdCinegrafistas || 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 errors = [];
|
||||||
|
|
||||||
const errFot = checkQuota(["fot"], acceptedFot, reqFot, "Fotografia");
|
const errFot = checkQuota(["fot"], acceptedFot, reqFot, "Fotografia");
|
||||||
|
|
@ -435,9 +467,24 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
||||||
|
|
||||||
const togglePhotographer = (photographerId: string) => {
|
const togglePhotographer = (photographerId: string) => {
|
||||||
if (!selectedEvent) return;
|
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);
|
assignPhotographer(selectedEvent.id, photographerId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRoleSelect = (funcaoId: string) => {
|
||||||
|
if (roleSelectionProf && selectedEvent) {
|
||||||
|
assignPhotographer(selectedEvent.id, roleSelectionProf.id, funcaoId);
|
||||||
|
setRoleSelectionProf(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// --- RENDERS PER ROLE ---
|
// --- RENDERS PER ROLE ---
|
||||||
|
|
||||||
const renderRoleSpecificHeader = () => {
|
const renderRoleSpecificHeader = () => {
|
||||||
|
|
@ -576,8 +623,9 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
||||||
userRole={user.role}
|
userRole={user.role}
|
||||||
currentProfessionalId={currentProfessionalId}
|
currentProfessionalId={currentProfessionalId}
|
||||||
onAssignmentResponse={handleAssignmentResponse}
|
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
|
professionals={professionals} // Adicionar lista de profissionais
|
||||||
|
functions={functions}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1254,6 +1302,11 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-gray-500">
|
<span className="text-xs text-gray-500">
|
||||||
{assignment.status === "PENDENTE" ? "Convite Pendente" : "Confirmado"}
|
{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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1423,11 +1476,12 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
||||||
const isAssigned = !!status && status !== "REJEITADO";
|
const isAssigned = !!status && status !== "REJEITADO";
|
||||||
|
|
||||||
// Check if busy in other events on the same date
|
// 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.id !== selectedEvent.id &&
|
||||||
e.date === selectedEvent.date &&
|
e.date === selectedEvent.date &&
|
||||||
(e.assignments || []).some(a => a.professionalId === photographer.id && a.status === 'ACEITO')
|
(e.assignments || []).some(a => a.professionalId === photographer.id && a.status === 'ACEITO')
|
||||||
);
|
) : undefined;
|
||||||
|
const isBusy = !!busyEvent;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
|
|
@ -1493,7 +1547,10 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!status && (
|
{!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 ? <UserX size={14} /> : <UserCheck size={14} />}
|
||||||
{isBusy ? "Em outro evento" : "Disponível"}
|
{isBusy ? "Em outro evento" : "Disponível"}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -1658,6 +1715,36 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
||||||
onClose={() => setViewingProfessional(null)}
|
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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,7 @@ const EventDetails: React.FC = () => {
|
||||||
<Clock className="w-5 h-5 text-brand-purple mt-1" />
|
<Clock className="w-5 h-5 text-brand-purple mt-1" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-gray-500 uppercase font-bold">Horário</p>
|
<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>
|
</div>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
|
|
|
||||||
|
|
@ -71,8 +71,12 @@ export const Login: React.FC<LoginProps> = ({ onNavigate }) => {
|
||||||
|
|
||||||
const handleProfessionalChoice = (isProfessional: boolean) => {
|
const handleProfessionalChoice = (isProfessional: boolean) => {
|
||||||
setShowProfessionalPrompt(false);
|
setShowProfessionalPrompt(false);
|
||||||
setSelectedCadastroType(isProfessional ? 'professional' : 'client');
|
if (isProfessional) {
|
||||||
setShowAccessCodeModal(true);
|
window.location.href = "/cadastro-profissional";
|
||||||
|
} else {
|
||||||
|
setSelectedCadastroType('client');
|
||||||
|
setShowAccessCodeModal(true);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLogin = async (e: React.FormEvent) => {
|
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
|
* 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 {
|
try {
|
||||||
|
const body: any = { professional_id: professionalId };
|
||||||
|
if (funcaoId) {
|
||||||
|
body.funcao_id = funcaoId;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE_URL}/api/agenda/${eventId}/professionals`, {
|
const response = await fetch(`${API_BASE_URL}/api/agenda/${eventId}/professionals`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Authorization": `Bearer ${token}`
|
"Authorization": `Bearer ${token}`
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ professional_id: professionalId })
|
body: JSON.stringify(body)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,7 @@ export interface Assignment {
|
||||||
professionalId: string;
|
professionalId: string;
|
||||||
status: AssignmentStatus;
|
status: AssignmentStatus;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
|
funcaoId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EventData {
|
export interface EventData {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue