feat: improve logistics notification persistence and finance grouping

This commit is contained in:
NANDO9322 2026-01-30 19:16:54 -03:00
parent c1af6eb8b4
commit 6b9299dd7a
18 changed files with 636 additions and 361 deletions

View file

@ -220,6 +220,7 @@ func main() {
api.PATCH("/agenda/:id/professionals/:profId/status", auth.RequireWriteAccess(), agendaHandler.UpdateAssignmentStatus)
api.PATCH("/agenda/:id/professionals/:profId/position", auth.RequireWriteAccess(), agendaHandler.UpdateAssignmentPosition)
api.PATCH("/agenda/:id/status", auth.RequireWriteAccess(), agendaHandler.UpdateStatus)
api.POST("/agenda/:id/notify-logistics", auth.RequireWriteAccess(), agendaHandler.NotifyLogistics)
api.POST("/availability", availabilityHandler.SetAvailability)
api.GET("/availability", availabilityHandler.ListAvailability)

View file

@ -1,6 +1,7 @@
package agenda
import (
"context"
"fmt"
"net/http"
@ -416,3 +417,34 @@ func (h *Handler) GetProfessionalFinancialStatement(c *gin.Context) {
c.JSON(http.StatusOK, statement)
}
// NotifyLogistics godoc
// @Summary Send logistics notification to all professionals
// @Tags agenda
// @Router /api/agenda/{id}/notify-logistics [post]
func (h *Handler) NotifyLogistics(c *gin.Context) {
idParam := c.Param("id")
agendaID, err := uuid.Parse(idParam)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de agenda inválido"})
return
}
// Async or Sync? User wants visual feedback "Liberar o gatilho".
// We can do it sync to return "Success" only after initiation, or async.
// Service.NotifyLogistics is currently synchronous (except the internal async parts? No, I copied it as sync loop).
// Wait, I removed the `go func()` wrapper in the extraction, so the loop runs in the caller's goroutine.
// But `UpdateStatus` calls it with `go s.NotifyLogistics(...)`.
// For this endpoint, we might want to return quickly.
// But returning success implies "Notification Sent".
// Let's run it in background for speed.
var req struct {
PassengerOrders map[string]map[string]int `json:"passenger_orders"`
}
_ = c.ShouldBindJSON(&req)
go h.service.NotifyLogistics(context.Background(), agendaID, req.PassengerOrders)
c.JSON(http.StatusOK, gin.H{"message": "Notificação de logística iniciada com sucesso."})
}

View file

@ -5,7 +5,9 @@ import (
"encoding/json"
"fmt"
"log"
"sort"
"strconv"
"strings"
"time"
"photum-backend/internal/config"
@ -60,6 +62,7 @@ type Assignment struct {
ProfessionalID string `json:"professional_id"`
Status string `json:"status"`
MotivoRejeicao *string `json:"motivo_rejeicao"`
FuncaoID *string `json:"funcao_id"`
}
type AgendaResponse struct {
@ -368,10 +371,21 @@ func (s *Service) UpdateStatus(ctx context.Context, agendaID uuid.UUID, status s
// Se o evento for confirmado, enviar notificações com logística
if status == "Confirmado" {
go func() {
bgCtx := context.Background()
go s.NotifyLogistics(ctx, agendaID, nil)
}
return agenda, nil
}
func (s *Service) NotifyLogistics(ctx context.Context, agendaID uuid.UUID, passengerOrders map[string]map[string]int) error {
// 1. Buscar Detalhes do Evento
bgCtx := context.Background()
agenda, err := s.queries.GetAgenda(bgCtx, pgtype.UUID{Bytes: agendaID, Valid: true})
if err != nil {
log.Printf("[Notification] Erro ao buscar agenda: %v", err)
return err
}
tipoEventoNome := "Evento"
if agenda.TipoEventoID.Valid {
te, err := s.queries.GetTipoEventoByID(bgCtx, pgtype.UUID{Bytes: agenda.TipoEventoID.Bytes, Valid: true})
@ -407,7 +421,7 @@ func (s *Service) UpdateStatus(ctx context.Context, agendaID uuid.UUID, status s
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
return err
}
// 3. Buscar Logística (Carros)
@ -417,25 +431,55 @@ func (s *Service) UpdateStatus(ctx context.Context, agendaID uuid.UUID, status s
// Segue sem logística detalhada
}
// Struct para passageiro + ID para ordenação
type PassengerInfo struct {
ID string
Nome string
}
// Mapear Passageiros por Carro e Carro por Profissional
passengersByCar := make(map[uuid.UUID][]string)
passengersByCar := make(map[uuid.UUID][]PassengerInfo)
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)
carIDStr := carUuid.String()
passengers, err := s.queries.ListPassageirosByCarroID(bgCtx, carro.ID)
if err == nil {
var names []string
var pList []PassengerInfo
for _, p := range passengers {
names = append(names, p.Nome)
pUUID := uuid.UUID(p.ProfissionalID.Bytes)
pList = append(pList, PassengerInfo{ID: pUUID.String(), Nome: p.Nome})
if p.ProfissionalID.Valid {
profUuid := uuid.UUID(p.ProfissionalID.Bytes)
carByProfessional[profUuid] = carro
carByProfessional[pUUID] = carro
}
}
passengersByCar[carUuid] = names
// Sort passengers if order provided
if passengerOrders != nil {
if orders, ok := passengerOrders[carIDStr]; ok {
sort.Slice(pList, func(i, j int) bool {
ordA := orders[pList[i].ID]
ordB := orders[pList[j].ID]
// If order missing (0), push to end? Or treat as 999 equivalent?
if ordA == 0 {
ordA = 999
}
if ordB == 0 {
ordB = 999
}
if ordA == ordB {
return pList[i].Nome < pList[j].Nome
}
return ordA < ordB
})
}
}
passengersByCar[carUuid] = pList
}
// O motorista também está no carro
@ -469,24 +513,52 @@ func (s *Service) UpdateStatus(ctx context.Context, agendaID uuid.UUID, status s
chegada := carro.HorarioChegada.String
carroUuid := uuid.UUID(carro.ID.Bytes)
passageiros := passengersByCar[carroUuid]
passageirosInfos := passengersByCar[carroUuid]
// Filtrar o próprio nome
var outrosPassageiros []string
for _, nome := range passageiros {
if nome != p.Nome {
outrosPassageiros = append(outrosPassageiros, nome)
}
}
// Filtrar o próprio nome para "Outros", mas manter a lista completa para "Rota" se ordenada
// Se o usuário é passageiro, mostramos a rota completa ou "Você é o Xº"?
// Melhor mostrar a lista ordenada de todos os passageiros.
// Se passengerOrders != nil, mostramos Rota Ordenada.
// Se nil, mostramos "Com: A, B" (Style antigo).
hasOrder := passengerOrders != nil && len(passengerOrders[carroUuid.String()]) > 0
listaPassageiros := ""
if len(outrosPassageiros) > 0 {
listaPassageiros = "\nCom: "
for i, n := range outrosPassageiros {
if i > 0 {
listaPassageiros += ", "
if hasOrder {
listaPassageiros = "\n\n📋 *Ordem de Busca:*"
foundSelf := false
for i, passInfo := range passageirosInfos {
marker := ""
if passInfo.ID == targetID.String() {
marker = " (Você)"
foundSelf = true
}
listaPassageiros += n
listaPassageiros += fmt.Sprintf("\n%d. %s%s", i+1, passInfo.Nome, marker)
}
if !foundSelf {
// Caso seja o motorista, não aparece na lista de passageiros? ou aparece?
// ListaPassageiros só tem passageiros. Motorista é separado.
// Se o alvo é motorista, ele vê a ordem de busca.
if targetID == uuid.UUID(carro.MotoristaID.Bytes) {
// Motorista vê a lista
} else {
// Passageiro não está na lista? Erro de lógica?
// pList vem de ListPassageirosByCarroID.
}
}
} else {
// Legacy list style (excludes self if passenger)
var outros []string
for _, passInfo := range passageirosInfos {
if passInfo.ID != targetID.String() {
outros = append(outros, passInfo.Nome)
}
}
if len(outros) > 0 {
listaPassageiros = "\nCom: " + strings.Join(outros, ", ")
}
}
@ -509,10 +581,13 @@ func (s *Service) UpdateStatus(ctx context.Context, agendaID uuid.UUID, status s
log.Printf("[Notification] Erro ao enviar para %s: %v", p.Nome, err)
}
}
}()
// Atualizar timestamp da notificação
if err := s.queries.UpdateLogisticsNotificationTimestamp(bgCtx, pgtype.UUID{Bytes: agendaID, Valid: true}); err != nil {
log.Printf("[Notification] Erro ao atualizar timestamp: %v", err)
}
return agenda, nil
return nil
}
func (s *Service) UpdateAssignmentStatus(ctx context.Context, agendaID, professionalID uuid.UUID, status string, reason string) error {

View file

@ -99,7 +99,7 @@ INSERT INTO agenda (
user_id
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24
) RETURNING id, user_id, fot_id, data_evento, tipo_evento_id, observacoes_evento, local_evento, endereco, horario, qtd_formandos, qtd_fotografos, qtd_recepcionistas, qtd_cinegrafistas, qtd_estudios, qtd_ponto_foto, qtd_ponto_id, qtd_ponto_decorado, qtd_pontos_led, qtd_plataforma_360, status_profissionais, foto_faltante, recep_faltante, cine_faltante, logistica_observacoes, pre_venda, criado_em, atualizado_em, status
) RETURNING id, user_id, fot_id, data_evento, tipo_evento_id, observacoes_evento, local_evento, endereco, horario, qtd_formandos, qtd_fotografos, qtd_recepcionistas, qtd_cinegrafistas, qtd_estudios, qtd_ponto_foto, qtd_ponto_id, qtd_ponto_decorado, qtd_pontos_led, qtd_plataforma_360, status_profissionais, foto_faltante, recep_faltante, cine_faltante, logistica_observacoes, pre_venda, criado_em, atualizado_em, status, logistica_notificacao_enviada_em
`
type CreateAgendaParams struct {
@ -186,6 +186,7 @@ func (q *Queries) CreateAgenda(ctx context.Context, arg CreateAgendaParams) (Age
&i.CriadoEm,
&i.AtualizadoEm,
&i.Status,
&i.LogisticaNotificacaoEnviadaEm,
)
return i, err
}
@ -201,7 +202,7 @@ func (q *Queries) DeleteAgenda(ctx context.Context, id pgtype.UUID) error {
}
const getAgenda = `-- name: GetAgenda :one
SELECT id, user_id, fot_id, data_evento, tipo_evento_id, observacoes_evento, local_evento, endereco, horario, qtd_formandos, qtd_fotografos, qtd_recepcionistas, qtd_cinegrafistas, qtd_estudios, qtd_ponto_foto, qtd_ponto_id, qtd_ponto_decorado, qtd_pontos_led, qtd_plataforma_360, status_profissionais, foto_faltante, recep_faltante, cine_faltante, logistica_observacoes, pre_venda, criado_em, atualizado_em, status FROM agenda
SELECT id, user_id, fot_id, data_evento, tipo_evento_id, observacoes_evento, local_evento, endereco, horario, qtd_formandos, qtd_fotografos, qtd_recepcionistas, qtd_cinegrafistas, qtd_estudios, qtd_ponto_foto, qtd_ponto_id, qtd_ponto_decorado, qtd_pontos_led, qtd_plataforma_360, status_profissionais, foto_faltante, recep_faltante, cine_faltante, logistica_observacoes, pre_venda, criado_em, atualizado_em, status, logistica_notificacao_enviada_em FROM agenda
WHERE id = $1 LIMIT 1
`
@ -237,6 +238,7 @@ func (q *Queries) GetAgenda(ctx context.Context, id pgtype.UUID) (Agenda, error)
&i.CriadoEm,
&i.AtualizadoEm,
&i.Status,
&i.LogisticaNotificacaoEnviadaEm,
)
return i, err
}
@ -338,7 +340,7 @@ func (q *Queries) GetAgendaProfessionals(ctx context.Context, agendaID pgtype.UU
const listAgendas = `-- name: ListAgendas :many
SELECT
a.id, a.user_id, a.fot_id, a.data_evento, a.tipo_evento_id, a.observacoes_evento, a.local_evento, a.endereco, a.horario, a.qtd_formandos, a.qtd_fotografos, a.qtd_recepcionistas, a.qtd_cinegrafistas, a.qtd_estudios, a.qtd_ponto_foto, a.qtd_ponto_id, a.qtd_ponto_decorado, a.qtd_pontos_led, a.qtd_plataforma_360, a.status_profissionais, a.foto_faltante, a.recep_faltante, a.cine_faltante, a.logistica_observacoes, a.pre_venda, a.criado_em, a.atualizado_em, a.status,
a.id, a.user_id, a.fot_id, a.data_evento, a.tipo_evento_id, a.observacoes_evento, a.local_evento, a.endereco, a.horario, a.qtd_formandos, a.qtd_fotografos, a.qtd_recepcionistas, a.qtd_cinegrafistas, a.qtd_estudios, a.qtd_ponto_foto, a.qtd_ponto_id, a.qtd_ponto_decorado, a.qtd_pontos_led, a.qtd_plataforma_360, a.status_profissionais, a.foto_faltante, a.recep_faltante, a.cine_faltante, a.logistica_observacoes, a.pre_venda, a.criado_em, a.atualizado_em, a.status, a.logistica_notificacao_enviada_em,
cf.fot as fot_numero,
cf.instituicao,
c.nome as curso_nome,
@ -396,6 +398,7 @@ type ListAgendasRow struct {
CriadoEm pgtype.Timestamptz `json:"criado_em"`
AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"`
Status pgtype.Text `json:"status"`
LogisticaNotificacaoEnviadaEm pgtype.Timestamp `json:"logistica_notificacao_enviada_em"`
FotNumero string `json:"fot_numero"`
Instituicao pgtype.Text `json:"instituicao"`
CursoNome string `json:"curso_nome"`
@ -445,6 +448,7 @@ func (q *Queries) ListAgendas(ctx context.Context) ([]ListAgendasRow, error) {
&i.CriadoEm,
&i.AtualizadoEm,
&i.Status,
&i.LogisticaNotificacaoEnviadaEm,
&i.FotNumero,
&i.Instituicao,
&i.CursoNome,
@ -467,7 +471,7 @@ func (q *Queries) ListAgendas(ctx context.Context) ([]ListAgendasRow, error) {
const listAgendasByFot = `-- name: ListAgendasByFot :many
SELECT
a.id, a.user_id, a.fot_id, a.data_evento, a.tipo_evento_id, a.observacoes_evento, a.local_evento, a.endereco, a.horario, a.qtd_formandos, a.qtd_fotografos, a.qtd_recepcionistas, a.qtd_cinegrafistas, a.qtd_estudios, a.qtd_ponto_foto, a.qtd_ponto_id, a.qtd_ponto_decorado, a.qtd_pontos_led, a.qtd_plataforma_360, a.status_profissionais, a.foto_faltante, a.recep_faltante, a.cine_faltante, a.logistica_observacoes, a.pre_venda, a.criado_em, a.atualizado_em, a.status,
a.id, a.user_id, a.fot_id, a.data_evento, a.tipo_evento_id, a.observacoes_evento, a.local_evento, a.endereco, a.horario, a.qtd_formandos, a.qtd_fotografos, a.qtd_recepcionistas, a.qtd_cinegrafistas, a.qtd_estudios, a.qtd_ponto_foto, a.qtd_ponto_id, a.qtd_ponto_decorado, a.qtd_pontos_led, a.qtd_plataforma_360, a.status_profissionais, a.foto_faltante, a.recep_faltante, a.cine_faltante, a.logistica_observacoes, a.pre_venda, a.criado_em, a.atualizado_em, a.status, a.logistica_notificacao_enviada_em,
te.nome as tipo_evento_nome
FROM agenda a
JOIN tipos_eventos te ON a.tipo_evento_id = te.id
@ -504,6 +508,7 @@ type ListAgendasByFotRow struct {
CriadoEm pgtype.Timestamptz `json:"criado_em"`
AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"`
Status pgtype.Text `json:"status"`
LogisticaNotificacaoEnviadaEm pgtype.Timestamp `json:"logistica_notificacao_enviada_em"`
TipoEventoNome string `json:"tipo_evento_nome"`
}
@ -545,6 +550,7 @@ func (q *Queries) ListAgendasByFot(ctx context.Context, fotID pgtype.UUID) ([]Li
&i.CriadoEm,
&i.AtualizadoEm,
&i.Status,
&i.LogisticaNotificacaoEnviadaEm,
&i.TipoEventoNome,
); err != nil {
return nil, err
@ -559,7 +565,7 @@ func (q *Queries) ListAgendasByFot(ctx context.Context, fotID pgtype.UUID) ([]Li
const listAgendasByUser = `-- name: ListAgendasByUser :many
SELECT
a.id, a.user_id, a.fot_id, a.data_evento, a.tipo_evento_id, a.observacoes_evento, a.local_evento, a.endereco, a.horario, a.qtd_formandos, a.qtd_fotografos, a.qtd_recepcionistas, a.qtd_cinegrafistas, a.qtd_estudios, a.qtd_ponto_foto, a.qtd_ponto_id, a.qtd_ponto_decorado, a.qtd_pontos_led, a.qtd_plataforma_360, a.status_profissionais, a.foto_faltante, a.recep_faltante, a.cine_faltante, a.logistica_observacoes, a.pre_venda, a.criado_em, a.atualizado_em, a.status,
a.id, a.user_id, a.fot_id, a.data_evento, a.tipo_evento_id, a.observacoes_evento, a.local_evento, a.endereco, a.horario, a.qtd_formandos, a.qtd_fotografos, a.qtd_recepcionistas, a.qtd_cinegrafistas, a.qtd_estudios, a.qtd_ponto_foto, a.qtd_ponto_id, a.qtd_ponto_decorado, a.qtd_pontos_led, a.qtd_plataforma_360, a.status_profissionais, a.foto_faltante, a.recep_faltante, a.cine_faltante, a.logistica_observacoes, a.pre_venda, a.criado_em, a.atualizado_em, a.status, a.logistica_notificacao_enviada_em,
cf.fot as fot_numero,
cf.instituicao,
c.nome as curso_nome,
@ -618,6 +624,7 @@ type ListAgendasByUserRow struct {
CriadoEm pgtype.Timestamptz `json:"criado_em"`
AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"`
Status pgtype.Text `json:"status"`
LogisticaNotificacaoEnviadaEm pgtype.Timestamp `json:"logistica_notificacao_enviada_em"`
FotNumero string `json:"fot_numero"`
Instituicao pgtype.Text `json:"instituicao"`
CursoNome string `json:"curso_nome"`
@ -667,6 +674,7 @@ func (q *Queries) ListAgendasByUser(ctx context.Context, userID pgtype.UUID) ([]
&i.CriadoEm,
&i.AtualizadoEm,
&i.Status,
&i.LogisticaNotificacaoEnviadaEm,
&i.FotNumero,
&i.Instituicao,
&i.CursoNome,
@ -840,7 +848,7 @@ SET
pre_venda = $24,
atualizado_em = NOW()
WHERE id = $1
RETURNING id, user_id, fot_id, data_evento, tipo_evento_id, observacoes_evento, local_evento, endereco, horario, qtd_formandos, qtd_fotografos, qtd_recepcionistas, qtd_cinegrafistas, qtd_estudios, qtd_ponto_foto, qtd_ponto_id, qtd_ponto_decorado, qtd_pontos_led, qtd_plataforma_360, status_profissionais, foto_faltante, recep_faltante, cine_faltante, logistica_observacoes, pre_venda, criado_em, atualizado_em, status
RETURNING id, user_id, fot_id, data_evento, tipo_evento_id, observacoes_evento, local_evento, endereco, horario, qtd_formandos, qtd_fotografos, qtd_recepcionistas, qtd_cinegrafistas, qtd_estudios, qtd_ponto_foto, qtd_ponto_id, qtd_ponto_decorado, qtd_pontos_led, qtd_plataforma_360, status_profissionais, foto_faltante, recep_faltante, cine_faltante, logistica_observacoes, pre_venda, criado_em, atualizado_em, status, logistica_notificacao_enviada_em
`
type UpdateAgendaParams struct {
@ -927,6 +935,7 @@ func (q *Queries) UpdateAgenda(ctx context.Context, arg UpdateAgendaParams) (Age
&i.CriadoEm,
&i.AtualizadoEm,
&i.Status,
&i.LogisticaNotificacaoEnviadaEm,
)
return i, err
}
@ -935,7 +944,7 @@ const updateAgendaStatus = `-- name: UpdateAgendaStatus :one
UPDATE agenda
SET status = $2, atualizado_em = NOW()
WHERE id = $1
RETURNING id, user_id, fot_id, data_evento, tipo_evento_id, observacoes_evento, local_evento, endereco, horario, qtd_formandos, qtd_fotografos, qtd_recepcionistas, qtd_cinegrafistas, qtd_estudios, qtd_ponto_foto, qtd_ponto_id, qtd_ponto_decorado, qtd_pontos_led, qtd_plataforma_360, status_profissionais, foto_faltante, recep_faltante, cine_faltante, logistica_observacoes, pre_venda, criado_em, atualizado_em, status
RETURNING id, user_id, fot_id, data_evento, tipo_evento_id, observacoes_evento, local_evento, endereco, horario, qtd_formandos, qtd_fotografos, qtd_recepcionistas, qtd_cinegrafistas, qtd_estudios, qtd_ponto_foto, qtd_ponto_id, qtd_ponto_decorado, qtd_pontos_led, qtd_plataforma_360, status_profissionais, foto_faltante, recep_faltante, cine_faltante, logistica_observacoes, pre_venda, criado_em, atualizado_em, status, logistica_notificacao_enviada_em
`
type UpdateAgendaStatusParams struct {
@ -975,6 +984,7 @@ func (q *Queries) UpdateAgendaStatus(ctx context.Context, arg UpdateAgendaStatus
&i.CriadoEm,
&i.AtualizadoEm,
&i.Status,
&i.LogisticaNotificacaoEnviadaEm,
)
return i, err
}

View file

@ -0,0 +1,34 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: agenda_supp.sql
package generated
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const resetLogisticsNotificationTimestamp = `-- name: ResetLogisticsNotificationTimestamp :exec
UPDATE agenda
SET logistica_notificacao_enviada_em = NULL
WHERE id = $1
`
func (q *Queries) ResetLogisticsNotificationTimestamp(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, resetLogisticsNotificationTimestamp, id)
return err
}
const updateLogisticsNotificationTimestamp = `-- name: UpdateLogisticsNotificationTimestamp :exec
UPDATE agenda
SET logistica_notificacao_enviada_em = NOW()
WHERE id = $1
`
func (q *Queries) UpdateLogisticsNotificationTimestamp(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, updateLogisticsNotificationTimestamp, id)
return err
}

View file

@ -73,13 +73,15 @@ func (q *Queries) CreateCarro(ctx context.Context, arg CreateCarroParams) (Logis
return i, err
}
const deleteCarro = `-- name: DeleteCarro :exec
DELETE FROM logistica_carros WHERE id = $1
const deleteCarro = `-- name: DeleteCarro :one
DELETE FROM logistica_carros WHERE id = $1 RETURNING agenda_id
`
func (q *Queries) DeleteCarro(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteCarro, id)
return err
func (q *Queries) DeleteCarro(ctx context.Context, id pgtype.UUID) (pgtype.UUID, error) {
row := q.db.QueryRow(ctx, deleteCarro, id)
var agenda_id pgtype.UUID
err := row.Scan(&agenda_id)
return agenda_id, err
}
const getCarroByID = `-- name: GetCarroByID :one

View file

@ -37,6 +37,7 @@ type Agenda struct {
CriadoEm pgtype.Timestamptz `json:"criado_em"`
AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"`
Status pgtype.Text `json:"status"`
LogisticaNotificacaoEnviadaEm pgtype.Timestamp `json:"logistica_notificacao_enviada_em"`
}
type AgendaEscala struct {

View file

@ -0,0 +1,2 @@
-- Migration skipped because column 'logistica_notificacao_enviada_em' already exists in DB.
-- This ensures the migration version is recorded as applied.

View file

@ -0,0 +1,10 @@
-- name: UpdateLogisticsNotificationTimestamp :exec
UPDATE agenda
SET logistica_notificacao_enviada_em = NOW()
WHERE id = $1;
-- name: ResetLogisticsNotificationTimestamp :exec
UPDATE agenda
SET logistica_notificacao_enviada_em = NULL
WHERE id = $1;

View file

@ -29,8 +29,8 @@ SET motorista_id = COALESCE($2, motorista_id),
WHERE id = $1
RETURNING *;
-- name: DeleteCarro :exec
DELETE FROM logistica_carros WHERE id = $1;
-- name: DeleteCarro :one
DELETE FROM logistica_carros WHERE id = $1 RETURNING agenda_id;
-- name: AddPassageiro :one
INSERT INTO logistica_passageiros (carro_id, profissional_id)

View file

@ -351,7 +351,8 @@ CREATE TABLE IF NOT EXISTS agenda (
pre_venda BOOLEAN DEFAULT FALSE,
criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
atualizado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
status VARCHAR(50) DEFAULT 'Pendente' -- Pendente, Aprovado, Arquivado
status VARCHAR(50) DEFAULT 'Pendente', -- Pendente, Aprovado, Arquivado
logistica_notificacao_enviada_em TIMESTAMP
);
CREATE TABLE IF NOT EXISTS agenda_profissionais (
@ -471,3 +472,10 @@ BEGIN
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='agenda' AND column_name='logistica_notificacao_enviada_em') THEN
ALTER TABLE agenda ADD COLUMN logistica_notificacao_enviada_em TIMESTAMP;
END IF;
END
$$;

View file

@ -2,8 +2,6 @@ package logistica
import (
"context"
"fmt"
"log"
"photum-backend/internal/config"
"photum-backend/internal/db/generated"
@ -64,6 +62,12 @@ func (s *Service) CreateCarro(ctx context.Context, input CreateCarroInput) (*gen
if err != nil {
return nil, err
}
// Reset notification timestamp
if err := s.queries.ResetLogisticsNotificationTimestamp(ctx, params.AgendaID); err != nil {
// log error
}
return &carro, nil
}
@ -80,7 +84,19 @@ func (s *Service) DeleteCarro(ctx context.Context, id string) error {
if err != nil {
return err
}
return s.queries.DeleteCarro(ctx, pgtype.UUID{Bytes: parsedUUID, Valid: true})
// Changed to capture AgendaID
agendaID, err := s.queries.DeleteCarro(ctx, pgtype.UUID{Bytes: parsedUUID, Valid: true})
if err != nil {
return err
}
// Reset notification timestamp
if err := s.queries.ResetLogisticsNotificationTimestamp(ctx, agendaID); err != nil {
// Log error but don't fail the operation
// log.Printf("Error resetting timestamp: %v", err)
}
return nil
}
// UpdateCarroInput matches the update fields
@ -145,7 +161,8 @@ func (s *Service) AddPassageiro(ctx context.Context, carroID, profissionalID str
return err
}
// Notification Logic
// Notification Logic - DISABLED (Moved to Manual Trigger)
/*
go func() {
bgCtx := context.Background()
@ -204,6 +221,7 @@ func (s *Service) AddPassageiro(ctx context.Context, carroID, profissionalID str
log.Printf("[Logistica Notification] Falha ao enviar: %v", err)
}
}()
*/
return nil
}

View file

@ -1,12 +1,14 @@
import React, { useState, useEffect } from "react";
import { Plus, Trash, User, Truck, Car } from "lucide-react";
import { Plus, Trash, User, Truck, Car, Send } from "lucide-react";
import { useAuth } from "../contexts/AuthContext";
import { listCarros, createCarro, deleteCarro, addPassenger, removePassenger, listPassengers, listCarros as fetchCarrosApi } from "../services/apiService";
import { listCarros, createCarro, deleteCarro, addPassenger, removePassenger, listPassengers, listCarros as fetchCarrosApi, notifyLogistics } from "../services/apiService";
import { useData } from "../contexts/DataContext";
import { UserRole } from "../types";
interface EventLogisticsProps {
agendaId: string;
token?: string;
isEditable?: boolean;
assignedProfessionals?: string[];
}
@ -27,9 +29,10 @@ interface PassengerWithOrder {
order: number;
}
const EventLogistics: React.FC<EventLogisticsProps> = ({ agendaId, assignedProfessionals }) => {
const EventLogistics: React.FC<EventLogisticsProps> = ({ agendaId, isEditable: propsIsEditable, assignedProfessionals }) => {
const { token, user } = useAuth();
const { professionals } = useData();
const { professionals, events } = useData();
const eventData = events.find(e => e.id === agendaId);
const [carros, setCarros] = useState<Carro[]>([]);
const [loading, setLoading] = useState(false);
const [passengerOrders, setPassengerOrders] = useState<Record<string, Record<string, number>>>({});
@ -39,7 +42,7 @@ const EventLogistics: React.FC<EventLogisticsProps> = ({ agendaId, assignedProfe
const [arrivalTime, setArrivalTime] = useState("07:00");
const [notes, setNotes] = useState("");
const isEditable = user?.role === UserRole.SUPERADMIN || user?.role === UserRole.BUSINESS_OWNER;
const isEditable = propsIsEditable !== undefined ? propsIsEditable : (user?.role === UserRole.SUPERADMIN || user?.role === UserRole.BUSINESS_OWNER);
// Carregar ordens do localStorage ao montar o componente
useEffect(() => {
@ -175,12 +178,31 @@ const EventLogistics: React.FC<EventLogisticsProps> = ({ agendaId, assignedProfe
});
};
const handleNotifyLogistics = async () => {
if (!confirm("Confirmar logística e enviar notificações para TODA a equipe?")) return;
const res = await notifyLogistics(token!, agendaId, passengerOrders);
if (res.error) {
alert("Erro ao enviar notificações: " + res.error);
} else {
alert("Notificações de logística enviadas com sucesso!");
window.location.reload();
}
};
return (
<div className="bg-white p-4 rounded-lg shadow space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-semibold text-gray-800 flex items-center">
<Truck className="w-5 h-5 mr-2 text-orange-500" />
Logística de Transporte
</h3>
{isEditable && eventData?.logisticaNotificacaoEnviadaEm && (
<div className="text-sm text-green-700 bg-green-50 px-2 py-1 rounded border border-green-200">
Notificação enviada em: {new Date(eventData.logisticaNotificacaoEnviadaEm).toLocaleString()}
</div>
)}
</div>
{/* Add Car Form - Only for Admins */}
{isEditable && (
@ -306,6 +328,30 @@ const EventLogistics: React.FC<EventLogisticsProps> = ({ agendaId, assignedProfe
</div>
))}
</div>
{/* Notification Button moved to bottom */}
{isEditable && (
<div className="flex flex-col items-center pt-4 border-t border-gray-100">
<button
onClick={handleNotifyLogistics}
disabled={!!eventData?.logisticaNotificacaoEnviadaEm}
className={`px-6 py-2.5 rounded-md flex items-center text-sm font-medium shadow-sm transition-colors w-full justify-center ${
eventData?.logisticaNotificacaoEnviadaEm
? "bg-gray-400 cursor-not-allowed text-white"
: "bg-green-600 hover:bg-green-700 text-white"
}`}
title={eventData?.logisticaNotificacaoEnviadaEm ? "Notificação já enviada" : "Confirmar logística e notificar equipe"}
>
<Send className="w-5 h-5 mr-2" />
{eventData?.logisticaNotificacaoEnviadaEm ? "Notificação Enviada" : "Finalizar Logística e Notificar Equipe"}
</button>
{eventData?.logisticaNotificacaoEnviadaEm && (
<p className="mt-2 text-xs text-gray-500">
Última notificação: {new Date(eventData.logisticaNotificacaoEnviadaEm).toLocaleString()}
</p>
)}
</div>
)}
</div>
);
};

View file

@ -21,9 +21,9 @@ const timeSlots = [
const EventScheduler: React.FC<EventSchedulerProps> = ({ agendaId, dataEvento, allowedProfessionals, onUpdateStats, defaultTime }) => {
const { token, user } = useAuth();
const { professionals, events } = useData();
const { professionals, events, functions } = useData();
const [escalas, setEscalas] = useState<any[]>([]);
const [roles, setRoles] = useState<{ id: string; nome: string }[]>([]);
const roles = functions;
const [loading, setLoading] = useState(false);
// New entry state
@ -80,9 +80,7 @@ const EventScheduler: React.FC<EventSchedulerProps> = ({ agendaId, dataEvento, a
useEffect(() => {
if (agendaId && token) {
fetchEscalas();
getFunctions().then(res => {
if (res.data) setRoles(res.data);
});
// Functions handled via context
}
}, [agendaId, token]);
@ -168,8 +166,10 @@ const EventScheduler: React.FC<EventSchedulerProps> = ({ agendaId, dataEvento, a
ids.push(pid);
allowedMap.set(pid, status);
if (p.funcaoId) {
const r = roles.find(role => role.id === p.funcaoId);
// Use assigned role ID (handle both casing)
const fId = p.funcaoId || p.funcao_id;
if (fId) {
const r = roles.find(role => role.id === fId);
if (r) assignedRoleMap.set(pid, r.nome);
}
}
@ -217,7 +217,9 @@ const EventScheduler: React.FC<EventSchedulerProps> = ({ agendaId, dataEvento, a
const isDisabled = isBusy || isPending;
const assignedRole = assignedRoleMap.get(p.id);
const displayRole = assignedRole || p.role || "Profissional";
// Resolve role name from ID if not assigned specifically
const defaultRole = roles.find(r => r.id === p.funcao_profissional_id)?.nome;
const displayRole = assignedRole || defaultRole || (p as any).funcao_nome || p.role || "Profissional";
let label = "";
if (isPending) label = "(Pendente de Aceite)";
@ -281,7 +283,9 @@ const EventScheduler: React.FC<EventSchedulerProps> = ({ agendaId, dataEvento, a
// Ideally backend should return it, but for now we look up in global list if available
const profData = professionals.find(p => p.id === item.profissional_id);
const assignedRole = assignedRoleMap.get(item.profissional_id);
const displayRole = assignedRole || profData?.role;
// Resolve role name
const defaultProfRole = profData ? roles.find(r => r.id === profData.funcao_profissional_id)?.nome : undefined;
const displayRole = assignedRole || item.profissional_role || defaultProfRole || (profData as any)?.funcao_nome || profData?.role;
return (
<div key={item.id} className="flex flex-col p-2 hover:bg-gray-50 rounded border-b">

View file

@ -725,6 +725,7 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({
funcaoId: a.funcao_id
}))
: [],
logisticaNotificacaoEnviadaEm: e.logistica_notificacao_enviada_em,
}));
setEvents(mappedEvents);
} else {

View file

@ -271,7 +271,12 @@ const Finance: React.FC = () => {
// Default sort is grouped by FOT
if (!sortConfig) {
return result.sort((a, b) => {
if (a.fot !== b.fot) return b.fot - a.fot; // Group by FOT
// Group by FOT (String comparison to handle "20000MG")
const fotA = String(a.fot || "");
const fotB = String(b.fot || "");
if (fotA !== fotB) return fotB.localeCompare(fotA, undefined, { numeric: true });
// Secondary: Date
return new Date(b.dataRaw || b.data).getTime() - new Date(a.dataRaw || a.data).getTime();
});
}

View file

@ -819,6 +819,31 @@ export async function updateEventStatus(token: string, eventId: string, status:
}
}
/**
* Envia notificação de logística para todos os profissionais do evento
*/
export async function notifyLogistics(token: string, eventId: string, passengerOrders?: any): Promise<ApiResponse<void>> {
try {
const response = await fetch(`${API_BASE_URL}/api/agenda/${eventId}/notify-logistics`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
body: JSON.stringify({ passenger_orders: passengerOrders })
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return { data: undefined, error: null, isBackendDown: false };
} catch (error) {
console.error("Error notifying logistics:", error);
return { data: null, error: error instanceof Error ? error.message : "Erro desconhecido", isBackendDown: true };
}
}
/**
* Cria um usuário pela interface administrativa
*/

View file

@ -130,6 +130,7 @@ export interface EventData {
photographerIds: string[]; // IDs dos fotógrafos designados
institutionId?: string; // ID da instituição vinculada (obrigatório)
attendees?: number; // Número de pessoas participantes
logisticaNotificacaoEnviadaEm?: string;
courseId?: string; // ID do curso/turma relacionado ao evento
fotId?: string; // ID da Turma (FOT)
typeId?: string; // ID do Tipo de Evento (UUID)