photum/backend/internal/agenda/service.go
NANDO9322 7536bddacb feat: sistema de validação de conflitos e melhorias na UX da agenda
Implementa validação de horários para evitar conflitos no aceite de eventos, correções na sincronização de dados da agenda e melhorias na interface de gestão de equipe.

Backend:
- handler.go: Correção no retorno do endpoint [UpdateAssignmentStatus](cci:1://file:///c:/Projetos/photum/backend/internal/agenda/handler.go:279:0-313:1) para enviar JSON válido e evitar erros no frontend.
- service.go: Implementação da lógica de validação de conflitos antes de aceitar um evento.
- agenda.sql: Nova query `CheckProfessionalBusyDate` para verificação de sobreposição de horários.

Frontend:
- Dashboard.tsx: Adição de tooltip e texto para exibir o "Motivo da Rejeição" na gestão de equipe (Desktop/Mobile).
- EventScheduler.tsx: Filtro para excluir profissionais com status 'REJEITADO' e correção na label de 'Pendente'.
- EventDetails.tsx: Refatoração para usar estado global ([useData](cci:1://file:///c:/Projetos/photum/frontend/contexts/DataContext.tsx:1156:0-1160:2)), garantindo atualização imediata de datas e locais.
- DataContext.tsx: Mapeamento do campo `local_evento` e melhoria no tratamento de erro otimista.
- Ajustes gerais em ProfessionalDetailsModal, Login e correções de tipos.
2025-12-30 11:24:53 -03:00

338 lines
14 KiB
Go

package agenda
import (
"context"
"encoding/json"
"fmt"
"strconv"
"time"
"photum-backend/internal/db/generated"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
type Service struct {
queries *generated.Queries
}
func NewService(db *generated.Queries) *Service {
return &Service{queries: db}
}
type CreateAgendaRequest struct {
FotID uuid.UUID `json:"fot_id"`
DataEvento time.Time `json:"data_evento"`
TipoEventoID uuid.UUID `json:"tipo_evento_id"`
ObservacoesEvento string `json:"observacoes_evento"`
LocalEvento string `json:"local_evento"`
Endereco string `json:"endereco"`
Horario string `json:"horario"`
QtdFormandos int32 `json:"qtd_formandos"`
QtdFotografos int32 `json:"qtd_fotografos"`
QtdRecepcionistas int32 `json:"qtd_recepcionistas"`
QtdCinegrafistas int32 `json:"qtd_cinegrafistas"`
QtdEstudios int32 `json:"qtd_estudios"`
QtdPontoFoto int32 `json:"qtd_ponto_foto"`
QtdPontoID int32 `json:"qtd_ponto_id"`
QtdPontoDecorado int32 `json:"qtd_ponto_decorado"`
QtdPontosLed int32 `json:"qtd_pontos_led"`
QtdPlataforma360 int32 `json:"qtd_plataforma_360"`
StatusProfissionais string `json:"status_profissionais"`
FotoFaltante int32 `json:"foto_faltante"`
RecepFaltante int32 `json:"recep_faltante"`
CineFaltante int32 `json:"cine_faltante"`
LogisticaObservacoes string `json:"logistica_observacoes"`
PreVenda bool `json:"pre_venda"`
}
type Assignment struct {
ProfessionalID string `json:"professional_id"`
Status string `json:"status"`
MotivoRejeicao *string `json:"motivo_rejeicao"`
}
type AgendaResponse struct {
generated.ListAgendasRow
ParsedAssignments []Assignment `json:"assignments"`
}
func (s *Service) CalculateStatus(fotoFaltante, recepFaltante, cineFaltante int32) string {
if fotoFaltante < 0 || recepFaltante < 0 || cineFaltante < 0 {
return "ERRO"
}
sum := fotoFaltante + recepFaltante + cineFaltante
if sum == 0 {
return "OK"
} else if sum > 0 {
return "FALTA"
}
return "ERRO"
}
func (s *Service) Create(ctx context.Context, userID uuid.UUID, req CreateAgendaRequest) (generated.Agenda, error) {
status := s.CalculateStatus(req.FotoFaltante, req.RecepFaltante, req.CineFaltante)
params := generated.CreateAgendaParams{
FotID: pgtype.UUID{Bytes: req.FotID, Valid: true},
DataEvento: pgtype.Date{Time: req.DataEvento, Valid: true},
TipoEventoID: pgtype.UUID{Bytes: req.TipoEventoID, Valid: true},
ObservacoesEvento: pgtype.Text{String: req.ObservacoesEvento, Valid: req.ObservacoesEvento != ""},
LocalEvento: pgtype.Text{String: req.LocalEvento, Valid: req.LocalEvento != ""},
Endereco: pgtype.Text{String: req.Endereco, Valid: req.Endereco != ""},
Horario: pgtype.Text{String: req.Horario, Valid: req.Horario != ""},
QtdFormandos: pgtype.Int4{Int32: req.QtdFormandos, Valid: true},
QtdFotografos: pgtype.Int4{Int32: req.QtdFotografos, Valid: true},
QtdRecepcionistas: pgtype.Int4{Int32: req.QtdRecepcionistas, Valid: true},
QtdCinegrafistas: pgtype.Int4{Int32: req.QtdCinegrafistas, Valid: true},
QtdEstudios: pgtype.Int4{Int32: req.QtdEstudios, Valid: true},
QtdPontoFoto: pgtype.Int4{Int32: req.QtdPontoFoto, Valid: true},
QtdPontoID: pgtype.Int4{Int32: req.QtdPontoID, Valid: true},
QtdPontoDecorado: pgtype.Int4{Int32: req.QtdPontoDecorado, Valid: true},
QtdPontosLed: pgtype.Int4{Int32: req.QtdPontosLed, Valid: true},
QtdPlataforma360: pgtype.Int4{Int32: req.QtdPlataforma360, Valid: true},
StatusProfissionais: pgtype.Text{String: status, Valid: true},
FotoFaltante: pgtype.Int4{Int32: req.FotoFaltante, Valid: true},
RecepFaltante: pgtype.Int4{Int32: req.RecepFaltante, Valid: true},
CineFaltante: pgtype.Int4{Int32: req.CineFaltante, Valid: true},
LogisticaObservacoes: pgtype.Text{String: req.LogisticaObservacoes, Valid: req.LogisticaObservacoes != ""},
PreVenda: pgtype.Bool{Bool: req.PreVenda, Valid: true},
UserID: pgtype.UUID{Bytes: userID, Valid: true},
}
return s.queries.CreateAgenda(ctx, params)
}
func (s *Service) List(ctx context.Context, userID uuid.UUID, role string) ([]AgendaResponse, error) {
var rows []generated.ListAgendasRow
var err error
// If role is CLIENT (cliente), filter by userID
if role == "cliente" || role == "EVENT_OWNER" {
listRows, err := s.queries.ListAgendasByUser(ctx, pgtype.UUID{Bytes: userID, Valid: true})
if err != nil {
return nil, err
}
// Convert ListAgendasByUserRow to ListAgendasRow manually
for _, r := range listRows {
rows = append(rows, generated.ListAgendasRow{
ID: r.ID,
UserID: r.UserID,
FotID: r.FotID,
DataEvento: r.DataEvento,
TipoEventoID: r.TipoEventoID,
ObservacoesEvento: r.ObservacoesEvento,
LocalEvento: r.LocalEvento,
Endereco: r.Endereco,
Horario: r.Horario,
QtdFormandos: r.QtdFormandos,
QtdFotografos: r.QtdFotografos,
QtdRecepcionistas: r.QtdRecepcionistas,
QtdCinegrafistas: r.QtdCinegrafistas,
QtdEstudios: r.QtdEstudios,
QtdPontoFoto: r.QtdPontoFoto,
QtdPontoID: r.QtdPontoID,
QtdPontoDecorado: r.QtdPontoDecorado,
QtdPontosLed: r.QtdPontosLed,
QtdPlataforma360: r.QtdPlataforma360,
StatusProfissionais: r.StatusProfissionais,
FotoFaltante: r.FotoFaltante,
RecepFaltante: r.RecepFaltante,
CineFaltante: r.CineFaltante,
LogisticaObservacoes: r.LogisticaObservacoes,
PreVenda: r.PreVenda,
CriadoEm: r.CriadoEm,
AtualizadoEm: r.AtualizadoEm,
Status: r.Status,
FotNumero: r.FotNumero,
Instituicao: r.Instituicao,
CursoNome: r.CursoNome,
EmpresaNome: r.EmpresaNome,
EmpresaID: r.EmpresaID,
AnoSemestre: r.AnoSemestre,
ObservacoesFot: r.ObservacoesFot,
TipoEventoNome: r.TipoEventoNome,
AssignedProfessionals: r.AssignedProfessionals,
})
}
} else {
rows, err = s.queries.ListAgendas(ctx)
if err != nil {
return nil, err
}
}
var response []AgendaResponse
for _, row := range rows {
var assignments []Assignment
if row.AssignedProfessionals != nil {
bytes, ok := row.AssignedProfessionals.([]byte)
if !ok {
str, ok := row.AssignedProfessionals.(string)
if ok {
bytes = []byte(str)
}
}
if bytes != nil {
json.Unmarshal(bytes, &assignments)
}
}
response = append(response, AgendaResponse{
ListAgendasRow: row,
ParsedAssignments: assignments,
})
}
return response, nil
}
func (s *Service) Get(ctx context.Context, id uuid.UUID) (generated.Agenda, error) {
return s.queries.GetAgenda(ctx, pgtype.UUID{Bytes: id, Valid: true})
}
func (s *Service) Update(ctx context.Context, id uuid.UUID, req CreateAgendaRequest) (generated.Agenda, error) {
if req.FotID == uuid.Nil {
return generated.Agenda{}, fmt.Errorf("FOT ID inválido ou não informado")
}
if req.TipoEventoID == uuid.Nil {
return generated.Agenda{}, fmt.Errorf("Tipo de Evento ID inválido ou não informado")
}
status := s.CalculateStatus(req.FotoFaltante, req.RecepFaltante, req.CineFaltante)
params := generated.UpdateAgendaParams{
ID: pgtype.UUID{Bytes: id, Valid: true},
FotID: pgtype.UUID{Bytes: req.FotID, Valid: true},
DataEvento: pgtype.Date{Time: req.DataEvento, Valid: true},
TipoEventoID: pgtype.UUID{Bytes: req.TipoEventoID, Valid: true},
ObservacoesEvento: pgtype.Text{String: req.ObservacoesEvento, Valid: req.ObservacoesEvento != ""},
LocalEvento: pgtype.Text{String: req.LocalEvento, Valid: req.LocalEvento != ""},
Endereco: pgtype.Text{String: req.Endereco, Valid: req.Endereco != ""},
Horario: pgtype.Text{String: req.Horario, Valid: req.Horario != ""},
QtdFormandos: pgtype.Int4{Int32: req.QtdFormandos, Valid: true},
QtdFotografos: pgtype.Int4{Int32: req.QtdFotografos, Valid: true},
QtdRecepcionistas: pgtype.Int4{Int32: req.QtdRecepcionistas, Valid: true},
QtdCinegrafistas: pgtype.Int4{Int32: req.QtdCinegrafistas, Valid: true},
QtdEstudios: pgtype.Int4{Int32: req.QtdEstudios, Valid: true},
QtdPontoFoto: pgtype.Int4{Int32: req.QtdPontoFoto, Valid: true},
QtdPontoID: pgtype.Int4{Int32: req.QtdPontoID, Valid: true},
QtdPontoDecorado: pgtype.Int4{Int32: req.QtdPontoDecorado, Valid: true},
QtdPontosLed: pgtype.Int4{Int32: req.QtdPontosLed, Valid: true},
QtdPlataforma360: pgtype.Int4{Int32: req.QtdPlataforma360, Valid: true},
StatusProfissionais: pgtype.Text{String: status, Valid: true},
FotoFaltante: pgtype.Int4{Int32: req.FotoFaltante, Valid: true},
RecepFaltante: pgtype.Int4{Int32: req.RecepFaltante, Valid: true},
CineFaltante: pgtype.Int4{Int32: req.CineFaltante, Valid: true},
LogisticaObservacoes: pgtype.Text{String: req.LogisticaObservacoes, Valid: req.LogisticaObservacoes != ""},
PreVenda: pgtype.Bool{Bool: req.PreVenda, Valid: true},
}
return s.queries.UpdateAgenda(ctx, params)
}
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 {
params := generated.AssignProfessionalParams{
AgendaID: pgtype.UUID{Bytes: agendaID, Valid: true},
ProfissionalID: pgtype.UUID{Bytes: profID, Valid: true},
}
return s.queries.AssignProfessional(ctx, params)
}
func (s *Service) RemoveProfessional(ctx context.Context, agendaID uuid.UUID, profID uuid.UUID) error {
params := generated.RemoveProfessionalParams{
AgendaID: pgtype.UUID{Bytes: agendaID, Valid: true},
ProfissionalID: pgtype.UUID{Bytes: profID, Valid: true},
}
return s.queries.RemoveProfessional(ctx, params)
}
func (s *Service) GetAgendaProfessionals(ctx context.Context, agendaID uuid.UUID) ([]generated.GetAgendaProfessionalsRow, error) {
return s.queries.GetAgendaProfessionals(ctx, pgtype.UUID{Bytes: agendaID, Valid: true})
}
func (s *Service) UpdateStatus(ctx context.Context, agendaID uuid.UUID, status string) (generated.Agenda, error) {
params := generated.UpdateAgendaStatusParams{
ID: pgtype.UUID{Bytes: agendaID, Valid: true},
Status: pgtype.Text{String: status, Valid: true},
}
return s.queries.UpdateAgendaStatus(ctx, params)
}
func (s *Service) UpdateAssignmentStatus(ctx context.Context, agendaID, professionalID uuid.UUID, status string, reason string) error {
// Conflict Validation on Accept
if status == "ACEITO" {
// 1. Get Current Agenda to know Date/Time
agenda, err := s.queries.GetAgenda(ctx, pgtype.UUID{Bytes: agendaID, Valid: true})
if err != nil {
return fmt.Errorf("erro ao buscar agenda para validação: %v", err)
}
if agenda.DataEvento.Valid {
// 2. Check for other confirmed events on the same date
// Exclude current agenda ID from check
conflicts, err := s.queries.CheckProfessionalBusyDate(ctx, generated.CheckProfessionalBusyDateParams{
ProfissionalID: pgtype.UUID{Bytes: professionalID, Valid: true},
DataEvento: agenda.DataEvento,
ID: pgtype.UUID{Bytes: agendaID, Valid: true},
})
if err != nil {
return fmt.Errorf("erro ao verificar disponibilidade: %v", err)
}
if len(conflicts) > 0 {
// 3. Check time overlap
currentStart := parseTimeMinutes(agenda.Horario.String)
currentEnd := currentStart + 240 // Assume 4 hours duration
for _, c := range conflicts {
conflictStart := parseTimeMinutes(c.Horario.String)
conflictEnd := conflictStart + 240
// Check overlap: StartA < EndB && StartB < EndA
if currentStart < conflictEnd && conflictStart < currentEnd {
return fmt.Errorf("conflito de horário: profissional já confirmou presença em outro evento às %s", c.Horario.String)
}
}
}
}
}
params := generated.UpdateAssignmentStatusParams{
AgendaID: pgtype.UUID{Bytes: agendaID, Valid: true},
ProfissionalID: pgtype.UUID{Bytes: professionalID, Valid: true},
Status: pgtype.Text{String: status, Valid: true},
MotivoRejeicao: pgtype.Text{String: reason, Valid: reason != ""},
}
_, err := s.queries.UpdateAssignmentStatus(ctx, params)
return err
}
// Helper for time parsing (HH:MM)
func parseTimeMinutes(t string) int {
if len(t) < 5 {
return 0 // Default or Error
}
h, _ := strconv.Atoi(t[0:2])
m, _ := strconv.Atoi(t[3:5])
return h*60 + m
}
func (s *Service) UpdateAssignmentPosition(ctx context.Context, agendaID, professionalID uuid.UUID, posicao string) error {
params := generated.UpdateAssignmentPositionParams{
AgendaID: pgtype.UUID{Bytes: agendaID, Valid: true},
ProfissionalID: pgtype.UUID{Bytes: professionalID, Valid: true},
Posicao: pgtype.Text{String: posicao, Valid: true},
}
_, err := s.queries.UpdateAssignmentPosition(ctx, params)
return err
}
func (s *Service) ListAvailableProfessionals(ctx context.Context, date time.Time) ([]generated.ListAvailableProfessionalsForDateRow, error) {
return s.queries.ListAvailableProfessionalsForDate(ctx, pgtype.Date{Time: date, Valid: true})
}