Compare commits
10 commits
a8230769e6
...
c7ba7586b8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7ba7586b8 | ||
|
|
7a06d4e691 | ||
|
|
d2c37d7b2c | ||
|
|
2c6891b7ed | ||
|
|
20b60fcc27 | ||
|
|
8c6bb6dfa3 | ||
|
|
25aee29acd | ||
|
|
034557a06b | ||
|
|
cc7c7dccc4 | ||
|
|
b4b2f536f1 |
42 changed files with 3594 additions and 231 deletions
|
|
@ -270,6 +270,13 @@ func main() {
|
||||||
logisticaGroup.POST("/carros/:id/passageiros", logisticaHandler.AddPassenger)
|
logisticaGroup.POST("/carros/:id/passageiros", logisticaHandler.AddPassenger)
|
||||||
logisticaGroup.DELETE("/carros/:id/passageiros/:profID", logisticaHandler.RemovePassenger)
|
logisticaGroup.DELETE("/carros/:id/passageiros/:profID", logisticaHandler.RemovePassenger)
|
||||||
logisticaGroup.GET("/carros/:id/passageiros", logisticaHandler.ListPassengers)
|
logisticaGroup.GET("/carros/:id/passageiros", logisticaHandler.ListPassengers)
|
||||||
|
|
||||||
|
// Daily Invitations and Available Staff
|
||||||
|
logisticaGroup.GET("/disponiveis", logisticaHandler.ListProfissionaisDisponiveis)
|
||||||
|
logisticaGroup.POST("/convites", logisticaHandler.CreateConvite)
|
||||||
|
logisticaGroup.GET("/convites", logisticaHandler.ListConvitesProfissional)
|
||||||
|
logisticaGroup.PUT("/convites/:id", logisticaHandler.ResponderConvite)
|
||||||
|
logisticaGroup.GET("/convites-por-data", logisticaHandler.ListConvitesPorData)
|
||||||
}
|
}
|
||||||
|
|
||||||
codigosGroup := api.Group("/codigos-acesso")
|
codigosGroup := api.Group("/codigos-acesso")
|
||||||
|
|
|
||||||
|
|
@ -3244,6 +3244,9 @@ const docTemplate = `{
|
||||||
"horario": {
|
"horario": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"horario_fim": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"local_evento": {
|
"local_evento": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -3238,6 +3238,9 @@
|
||||||
"horario": {
|
"horario": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"horario_fim": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"local_evento": {
|
"local_evento": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ definitions:
|
||||||
type: integer
|
type: integer
|
||||||
horario:
|
horario:
|
||||||
type: string
|
type: string
|
||||||
|
horario_fim:
|
||||||
|
type: string
|
||||||
local_evento:
|
local_evento:
|
||||||
type: string
|
type: string
|
||||||
logistica_observacoes:
|
logistica_observacoes:
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ type CreateAgendaRequest struct {
|
||||||
LocalEvento string `json:"local_evento"`
|
LocalEvento string `json:"local_evento"`
|
||||||
Endereco string `json:"endereco"`
|
Endereco string `json:"endereco"`
|
||||||
Horario string `json:"horario"`
|
Horario string `json:"horario"`
|
||||||
|
HorarioFim string `json:"horario_fim"`
|
||||||
QtdFormandos int32 `json:"qtd_formandos"`
|
QtdFormandos int32 `json:"qtd_formandos"`
|
||||||
QtdFotografos int32 `json:"qtd_fotografos"`
|
QtdFotografos int32 `json:"qtd_fotografos"`
|
||||||
QtdRecepcionistas int32 `json:"qtd_recepcionistas"`
|
QtdRecepcionistas int32 `json:"qtd_recepcionistas"`
|
||||||
|
|
@ -125,6 +126,7 @@ func (s *Service) Create(ctx context.Context, userID uuid.UUID, req CreateAgenda
|
||||||
LocalEvento: pgtype.Text{String: req.LocalEvento, Valid: req.LocalEvento != ""},
|
LocalEvento: pgtype.Text{String: req.LocalEvento, Valid: req.LocalEvento != ""},
|
||||||
Endereco: pgtype.Text{String: req.Endereco, Valid: req.Endereco != ""},
|
Endereco: pgtype.Text{String: req.Endereco, Valid: req.Endereco != ""},
|
||||||
Horario: pgtype.Text{String: req.Horario, Valid: req.Horario != ""},
|
Horario: pgtype.Text{String: req.Horario, Valid: req.Horario != ""},
|
||||||
|
HorarioFim: pgtype.Text{String: req.HorarioFim, Valid: req.HorarioFim != ""},
|
||||||
QtdFormandos: pgtype.Int4{Int32: req.QtdFormandos, Valid: true},
|
QtdFormandos: pgtype.Int4{Int32: req.QtdFormandos, Valid: true},
|
||||||
QtdFotografos: pgtype.Int4{Int32: req.QtdFotografos, Valid: true},
|
QtdFotografos: pgtype.Int4{Int32: req.QtdFotografos, Valid: true},
|
||||||
QtdRecepcionistas: pgtype.Int4{Int32: req.QtdRecepcionistas, Valid: true},
|
QtdRecepcionistas: pgtype.Int4{Int32: req.QtdRecepcionistas, Valid: true},
|
||||||
|
|
@ -185,6 +187,7 @@ func (s *Service) List(ctx context.Context, userID uuid.UUID, role string, regia
|
||||||
LocalEvento: r.LocalEvento,
|
LocalEvento: r.LocalEvento,
|
||||||
Endereco: r.Endereco,
|
Endereco: r.Endereco,
|
||||||
Horario: r.Horario,
|
Horario: r.Horario,
|
||||||
|
HorarioFim: r.HorarioFim,
|
||||||
QtdFormandos: r.QtdFormandos,
|
QtdFormandos: r.QtdFormandos,
|
||||||
QtdFotografos: r.QtdFotografos,
|
QtdFotografos: r.QtdFotografos,
|
||||||
QtdRecepcionistas: r.QtdRecepcionistas,
|
QtdRecepcionistas: r.QtdRecepcionistas,
|
||||||
|
|
@ -304,6 +307,7 @@ func (s *Service) Update(ctx context.Context, id uuid.UUID, req CreateAgendaRequ
|
||||||
LocalEvento: pgtype.Text{String: req.LocalEvento, Valid: req.LocalEvento != ""},
|
LocalEvento: pgtype.Text{String: req.LocalEvento, Valid: req.LocalEvento != ""},
|
||||||
Endereco: pgtype.Text{String: req.Endereco, Valid: req.Endereco != ""},
|
Endereco: pgtype.Text{String: req.Endereco, Valid: req.Endereco != ""},
|
||||||
Horario: pgtype.Text{String: req.Horario, Valid: req.Horario != ""},
|
Horario: pgtype.Text{String: req.Horario, Valid: req.Horario != ""},
|
||||||
|
HorarioFim: pgtype.Text{String: req.HorarioFim, Valid: req.HorarioFim != ""},
|
||||||
QtdFormandos: pgtype.Int4{Int32: req.QtdFormandos, Valid: true},
|
QtdFormandos: pgtype.Int4{Int32: req.QtdFormandos, Valid: true},
|
||||||
QtdFotografos: pgtype.Int4{Int32: req.QtdFotografos, Valid: true},
|
QtdFotografos: pgtype.Int4{Int32: req.QtdFotografos, Valid: true},
|
||||||
QtdRecepcionistas: pgtype.Int4{Int32: req.QtdRecepcionistas, Valid: true},
|
QtdRecepcionistas: pgtype.Int4{Int32: req.QtdRecepcionistas, Valid: true},
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,12 @@ func (h *Handler) Register(c *gin.Context) {
|
||||||
MaxAge: 30 * 24 * 60 * 60,
|
MaxAge: 30 * 24 * 60 * 60,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Calculate MaxAge for access token cookie based on role
|
||||||
|
accessMaxAge := 180 * 60 // Default 3 hours
|
||||||
|
if req.Role == "RESEARCHER" {
|
||||||
|
accessMaxAge = 30 * 24 * 60 * 60 // 30 days
|
||||||
|
}
|
||||||
|
|
||||||
// Set access_token cookie for fallback
|
// Set access_token cookie for fallback
|
||||||
http.SetCookie(c.Writer, &http.Cookie{
|
http.SetCookie(c.Writer, &http.Cookie{
|
||||||
Name: "access_token",
|
Name: "access_token",
|
||||||
|
|
@ -154,7 +160,7 @@ func (h *Handler) Register(c *gin.Context) {
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Secure: false,
|
Secure: false,
|
||||||
SameSite: http.SameSiteStrictMode,
|
SameSite: http.SameSiteStrictMode,
|
||||||
MaxAge: 180 * 60, // 3 hours
|
MaxAge: accessMaxAge,
|
||||||
})
|
})
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, gin.H{
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
|
|
@ -235,6 +241,12 @@ func (h *Handler) Login(c *gin.Context) {
|
||||||
MaxAge: 30 * 24 * 60 * 60,
|
MaxAge: 30 * 24 * 60 * 60,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Calculate MaxAge for access token cookie based on role
|
||||||
|
accessMaxAge := 180 * 60 // Default 3 hours
|
||||||
|
if user.Role == "RESEARCHER" {
|
||||||
|
accessMaxAge = 30 * 24 * 60 * 60 // 30 days
|
||||||
|
}
|
||||||
|
|
||||||
// Set access_token cookie for fallback
|
// Set access_token cookie for fallback
|
||||||
http.SetCookie(c.Writer, &http.Cookie{
|
http.SetCookie(c.Writer, &http.Cookie{
|
||||||
Name: "access_token",
|
Name: "access_token",
|
||||||
|
|
@ -243,7 +255,7 @@ func (h *Handler) Login(c *gin.Context) {
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Secure: false,
|
Secure: false,
|
||||||
SameSite: http.SameSiteStrictMode,
|
SameSite: http.SameSiteStrictMode,
|
||||||
MaxAge: 180 * 60, // 3 hours
|
MaxAge: accessMaxAge,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Handle Nullable Fields
|
// Handle Nullable Fields
|
||||||
|
|
|
||||||
|
|
@ -168,6 +168,34 @@ func (s *Service) Login(ctx context.Context, email, senha string) (*TokenPair, *
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Persist the Refresh Token in the database
|
||||||
|
refreshHash := sha256.Sum256([]byte(refreshToken))
|
||||||
|
refreshHashString := hex.EncodeToString(refreshHash[:])
|
||||||
|
|
||||||
|
refreshExp := time.Now().Add(time.Duration(s.jwtRefreshTTLDays) * 24 * time.Hour)
|
||||||
|
// For Researchers, we can ensure the refresh token lives just as long as the extended access token + margin
|
||||||
|
if user.Role == "RESEARCHER" {
|
||||||
|
refreshExp = time.Now().Add(35 * 24 * time.Hour) // 35 days
|
||||||
|
}
|
||||||
|
|
||||||
|
var ip, userAgent string // We could pass them from context if needed, but for now leave blank
|
||||||
|
|
||||||
|
var expTime pgtype.Timestamptz
|
||||||
|
expTime.Time = refreshExp
|
||||||
|
expTime.Valid = true
|
||||||
|
|
||||||
|
_, err = s.queries.CreateRefreshToken(ctx, generated.CreateRefreshTokenParams{
|
||||||
|
UsuarioID: user.ID,
|
||||||
|
TokenHash: refreshHashString,
|
||||||
|
UserAgent: toPgText(&userAgent),
|
||||||
|
Ip: toPgText(&ip),
|
||||||
|
ExpiraEm: expTime,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[DEBUG] Failed to save refresh token: %v\n", err)
|
||||||
|
// We still return true login, but refresh might fail later
|
||||||
|
}
|
||||||
|
|
||||||
var profData *generated.GetProfissionalByUsuarioIDRow
|
var profData *generated.GetProfissionalByUsuarioIDRow
|
||||||
if user.Role == RolePhotographer || user.Role == RoleBusinessOwner {
|
if user.Role == RolePhotographer || user.Role == RoleBusinessOwner {
|
||||||
p, err := s.queries.GetProfissionalByUsuarioID(ctx, user.ID)
|
p, err := s.queries.GetProfissionalByUsuarioID(ctx, user.ID)
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,11 @@ type Claims struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func GenerateAccessToken(userID uuid.UUID, role string, regioes []string, secret string, ttlMinutes int) (string, time.Time, error) {
|
func GenerateAccessToken(userID uuid.UUID, role string, regioes []string, secret string, ttlMinutes int) (string, time.Time, error) {
|
||||||
|
// Extend TTL to 30 days specifically for RESEARCHER role to prevent session drop
|
||||||
|
if role == "RESEARCHER" {
|
||||||
|
ttlMinutes = 30 * 24 * 60
|
||||||
|
}
|
||||||
|
|
||||||
expirationTime := time.Now().Add(time.Duration(ttlMinutes) * time.Minute)
|
expirationTime := time.Now().Add(time.Duration(ttlMinutes) * time.Minute)
|
||||||
claims := &Claims{
|
claims := &Claims{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ func (q *Queries) AssignProfessional(ctx context.Context, arg AssignProfessional
|
||||||
}
|
}
|
||||||
|
|
||||||
const checkProfessionalBusyDate = `-- name: CheckProfessionalBusyDate :many
|
const checkProfessionalBusyDate = `-- name: CheckProfessionalBusyDate :many
|
||||||
SELECT a.id, a.horario, ap.status
|
SELECT a.id, a.horario, a.horario_fim, ap.status
|
||||||
FROM agenda_profissionais ap
|
FROM agenda_profissionais ap
|
||||||
JOIN agenda a ON ap.agenda_id = a.id
|
JOIN agenda a ON ap.agenda_id = a.id
|
||||||
WHERE ap.profissional_id = $1
|
WHERE ap.profissional_id = $1
|
||||||
|
|
@ -46,9 +46,10 @@ type CheckProfessionalBusyDateParams struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type CheckProfessionalBusyDateRow struct {
|
type CheckProfessionalBusyDateRow struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
Horario pgtype.Text `json:"horario"`
|
Horario pgtype.Text `json:"horario"`
|
||||||
Status pgtype.Text `json:"status"`
|
HorarioFim pgtype.Text `json:"horario_fim"`
|
||||||
|
Status pgtype.Text `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) CheckProfessionalBusyDate(ctx context.Context, arg CheckProfessionalBusyDateParams) ([]CheckProfessionalBusyDateRow, error) {
|
func (q *Queries) CheckProfessionalBusyDate(ctx context.Context, arg CheckProfessionalBusyDateParams) ([]CheckProfessionalBusyDateRow, error) {
|
||||||
|
|
@ -60,7 +61,12 @@ func (q *Queries) CheckProfessionalBusyDate(ctx context.Context, arg CheckProfes
|
||||||
var items []CheckProfessionalBusyDateRow
|
var items []CheckProfessionalBusyDateRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i CheckProfessionalBusyDateRow
|
var i CheckProfessionalBusyDateRow
|
||||||
if err := rows.Scan(&i.ID, &i.Horario, &i.Status); err != nil {
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Horario,
|
||||||
|
&i.HorarioFim,
|
||||||
|
&i.Status,
|
||||||
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items = append(items, i)
|
items = append(items, i)
|
||||||
|
|
@ -80,6 +86,7 @@ INSERT INTO agenda (
|
||||||
local_evento,
|
local_evento,
|
||||||
endereco,
|
endereco,
|
||||||
horario,
|
horario,
|
||||||
|
horario_fim,
|
||||||
qtd_formandos,
|
qtd_formandos,
|
||||||
qtd_fotografos,
|
qtd_fotografos,
|
||||||
qtd_recepcionistas,
|
qtd_recepcionistas,
|
||||||
|
|
@ -100,8 +107,8 @@ INSERT INTO agenda (
|
||||||
regiao,
|
regiao,
|
||||||
contatos
|
contatos
|
||||||
) VALUES (
|
) 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, $26, $25
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $27, $26
|
||||||
) 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, regiao, contatos
|
) RETURNING id, user_id, fot_id, data_evento, tipo_evento_id, observacoes_evento, local_evento, endereco, horario, horario_fim, 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, regiao, contatos
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateAgendaParams struct {
|
type CreateAgendaParams struct {
|
||||||
|
|
@ -112,6 +119,7 @@ type CreateAgendaParams struct {
|
||||||
LocalEvento pgtype.Text `json:"local_evento"`
|
LocalEvento pgtype.Text `json:"local_evento"`
|
||||||
Endereco pgtype.Text `json:"endereco"`
|
Endereco pgtype.Text `json:"endereco"`
|
||||||
Horario pgtype.Text `json:"horario"`
|
Horario pgtype.Text `json:"horario"`
|
||||||
|
HorarioFim pgtype.Text `json:"horario_fim"`
|
||||||
QtdFormandos pgtype.Int4 `json:"qtd_formandos"`
|
QtdFormandos pgtype.Int4 `json:"qtd_formandos"`
|
||||||
QtdFotografos pgtype.Int4 `json:"qtd_fotografos"`
|
QtdFotografos pgtype.Int4 `json:"qtd_fotografos"`
|
||||||
QtdRecepcionistas pgtype.Int4 `json:"qtd_recepcionistas"`
|
QtdRecepcionistas pgtype.Int4 `json:"qtd_recepcionistas"`
|
||||||
|
|
@ -142,6 +150,7 @@ func (q *Queries) CreateAgenda(ctx context.Context, arg CreateAgendaParams) (Age
|
||||||
arg.LocalEvento,
|
arg.LocalEvento,
|
||||||
arg.Endereco,
|
arg.Endereco,
|
||||||
arg.Horario,
|
arg.Horario,
|
||||||
|
arg.HorarioFim,
|
||||||
arg.QtdFormandos,
|
arg.QtdFormandos,
|
||||||
arg.QtdFotografos,
|
arg.QtdFotografos,
|
||||||
arg.QtdRecepcionistas,
|
arg.QtdRecepcionistas,
|
||||||
|
|
@ -173,6 +182,7 @@ func (q *Queries) CreateAgenda(ctx context.Context, arg CreateAgendaParams) (Age
|
||||||
&i.LocalEvento,
|
&i.LocalEvento,
|
||||||
&i.Endereco,
|
&i.Endereco,
|
||||||
&i.Horario,
|
&i.Horario,
|
||||||
|
&i.HorarioFim,
|
||||||
&i.QtdFormandos,
|
&i.QtdFormandos,
|
||||||
&i.QtdFotografos,
|
&i.QtdFotografos,
|
||||||
&i.QtdRecepcionistas,
|
&i.QtdRecepcionistas,
|
||||||
|
|
@ -215,7 +225,7 @@ func (q *Queries) DeleteAgenda(ctx context.Context, arg DeleteAgendaParams) erro
|
||||||
}
|
}
|
||||||
|
|
||||||
const getAgenda = `-- name: GetAgenda :one
|
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, logistica_notificacao_enviada_em, regiao, contatos FROM agenda
|
SELECT id, user_id, fot_id, data_evento, tipo_evento_id, observacoes_evento, local_evento, endereco, horario, horario_fim, 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, regiao, contatos FROM agenda
|
||||||
WHERE id = $1 AND regiao = $2 LIMIT 1
|
WHERE id = $1 AND regiao = $2 LIMIT 1
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|
@ -237,6 +247,7 @@ func (q *Queries) GetAgenda(ctx context.Context, arg GetAgendaParams) (Agenda, e
|
||||||
&i.LocalEvento,
|
&i.LocalEvento,
|
||||||
&i.Endereco,
|
&i.Endereco,
|
||||||
&i.Horario,
|
&i.Horario,
|
||||||
|
&i.HorarioFim,
|
||||||
&i.QtdFormandos,
|
&i.QtdFormandos,
|
||||||
&i.QtdFotografos,
|
&i.QtdFotografos,
|
||||||
&i.QtdRecepcionistas,
|
&i.QtdRecepcionistas,
|
||||||
|
|
@ -264,7 +275,7 @@ func (q *Queries) GetAgenda(ctx context.Context, arg GetAgendaParams) (Agenda, e
|
||||||
}
|
}
|
||||||
|
|
||||||
const getAgendaByFotDataTipo = `-- name: GetAgendaByFotDataTipo :one
|
const getAgendaByFotDataTipo = `-- name: GetAgendaByFotDataTipo :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, logistica_notificacao_enviada_em, regiao, contatos FROM agenda
|
SELECT id, user_id, fot_id, data_evento, tipo_evento_id, observacoes_evento, local_evento, endereco, horario, horario_fim, 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, regiao, contatos FROM agenda
|
||||||
WHERE fot_id = $1 AND data_evento = $2 AND tipo_evento_id = $3 AND regiao = $4
|
WHERE fot_id = $1 AND data_evento = $2 AND tipo_evento_id = $3 AND regiao = $4
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`
|
`
|
||||||
|
|
@ -294,6 +305,7 @@ func (q *Queries) GetAgendaByFotDataTipo(ctx context.Context, arg GetAgendaByFot
|
||||||
&i.LocalEvento,
|
&i.LocalEvento,
|
||||||
&i.Endereco,
|
&i.Endereco,
|
||||||
&i.Horario,
|
&i.Horario,
|
||||||
|
&i.HorarioFim,
|
||||||
&i.QtdFormandos,
|
&i.QtdFormandos,
|
||||||
&i.QtdFotografos,
|
&i.QtdFotografos,
|
||||||
&i.QtdRecepcionistas,
|
&i.QtdRecepcionistas,
|
||||||
|
|
@ -456,7 +468,7 @@ func (q *Queries) GetAssignment(ctx context.Context, arg GetAssignmentParams) (A
|
||||||
|
|
||||||
const listAgendas = `-- name: ListAgendas :many
|
const listAgendas = `-- name: ListAgendas :many
|
||||||
SELECT
|
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.logistica_notificacao_enviada_em, a.regiao, a.contatos,
|
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.horario_fim, 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, a.regiao, a.contatos,
|
||||||
cf.fot as fot_numero,
|
cf.fot as fot_numero,
|
||||||
cf.instituicao,
|
cf.instituicao,
|
||||||
c.nome as curso_nome,
|
c.nome as curso_nome,
|
||||||
|
|
@ -499,6 +511,7 @@ type ListAgendasRow struct {
|
||||||
LocalEvento pgtype.Text `json:"local_evento"`
|
LocalEvento pgtype.Text `json:"local_evento"`
|
||||||
Endereco pgtype.Text `json:"endereco"`
|
Endereco pgtype.Text `json:"endereco"`
|
||||||
Horario pgtype.Text `json:"horario"`
|
Horario pgtype.Text `json:"horario"`
|
||||||
|
HorarioFim pgtype.Text `json:"horario_fim"`
|
||||||
QtdFormandos pgtype.Int4 `json:"qtd_formandos"`
|
QtdFormandos pgtype.Int4 `json:"qtd_formandos"`
|
||||||
QtdFotografos pgtype.Int4 `json:"qtd_fotografos"`
|
QtdFotografos pgtype.Int4 `json:"qtd_fotografos"`
|
||||||
QtdRecepcionistas pgtype.Int4 `json:"qtd_recepcionistas"`
|
QtdRecepcionistas pgtype.Int4 `json:"qtd_recepcionistas"`
|
||||||
|
|
@ -553,6 +566,7 @@ func (q *Queries) ListAgendas(ctx context.Context, regiao pgtype.Text) ([]ListAg
|
||||||
&i.LocalEvento,
|
&i.LocalEvento,
|
||||||
&i.Endereco,
|
&i.Endereco,
|
||||||
&i.Horario,
|
&i.Horario,
|
||||||
|
&i.HorarioFim,
|
||||||
&i.QtdFormandos,
|
&i.QtdFormandos,
|
||||||
&i.QtdFotografos,
|
&i.QtdFotografos,
|
||||||
&i.QtdRecepcionistas,
|
&i.QtdRecepcionistas,
|
||||||
|
|
@ -599,7 +613,7 @@ func (q *Queries) ListAgendas(ctx context.Context, regiao pgtype.Text) ([]ListAg
|
||||||
|
|
||||||
const listAgendasByCompany = `-- name: ListAgendasByCompany :many
|
const listAgendasByCompany = `-- name: ListAgendasByCompany :many
|
||||||
SELECT
|
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.logistica_notificacao_enviada_em, a.regiao, a.contatos,
|
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.horario_fim, 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, a.regiao, a.contatos,
|
||||||
cf.fot as fot_numero,
|
cf.fot as fot_numero,
|
||||||
cf.instituicao,
|
cf.instituicao,
|
||||||
c.nome as curso_nome,
|
c.nome as curso_nome,
|
||||||
|
|
@ -647,6 +661,7 @@ type ListAgendasByCompanyRow struct {
|
||||||
LocalEvento pgtype.Text `json:"local_evento"`
|
LocalEvento pgtype.Text `json:"local_evento"`
|
||||||
Endereco pgtype.Text `json:"endereco"`
|
Endereco pgtype.Text `json:"endereco"`
|
||||||
Horario pgtype.Text `json:"horario"`
|
Horario pgtype.Text `json:"horario"`
|
||||||
|
HorarioFim pgtype.Text `json:"horario_fim"`
|
||||||
QtdFormandos pgtype.Int4 `json:"qtd_formandos"`
|
QtdFormandos pgtype.Int4 `json:"qtd_formandos"`
|
||||||
QtdFotografos pgtype.Int4 `json:"qtd_fotografos"`
|
QtdFotografos pgtype.Int4 `json:"qtd_fotografos"`
|
||||||
QtdRecepcionistas pgtype.Int4 `json:"qtd_recepcionistas"`
|
QtdRecepcionistas pgtype.Int4 `json:"qtd_recepcionistas"`
|
||||||
|
|
@ -701,6 +716,7 @@ func (q *Queries) ListAgendasByCompany(ctx context.Context, arg ListAgendasByCom
|
||||||
&i.LocalEvento,
|
&i.LocalEvento,
|
||||||
&i.Endereco,
|
&i.Endereco,
|
||||||
&i.Horario,
|
&i.Horario,
|
||||||
|
&i.HorarioFim,
|
||||||
&i.QtdFormandos,
|
&i.QtdFormandos,
|
||||||
&i.QtdFotografos,
|
&i.QtdFotografos,
|
||||||
&i.QtdRecepcionistas,
|
&i.QtdRecepcionistas,
|
||||||
|
|
@ -747,7 +763,7 @@ func (q *Queries) ListAgendasByCompany(ctx context.Context, arg ListAgendasByCom
|
||||||
|
|
||||||
const listAgendasByFot = `-- name: ListAgendasByFot :many
|
const listAgendasByFot = `-- name: ListAgendasByFot :many
|
||||||
SELECT
|
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.logistica_notificacao_enviada_em, a.regiao, a.contatos,
|
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.horario_fim, 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, a.regiao, a.contatos,
|
||||||
te.nome as tipo_evento_nome
|
te.nome as tipo_evento_nome
|
||||||
FROM agenda a
|
FROM agenda a
|
||||||
JOIN tipos_eventos te ON a.tipo_evento_id = te.id
|
JOIN tipos_eventos te ON a.tipo_evento_id = te.id
|
||||||
|
|
@ -770,6 +786,7 @@ type ListAgendasByFotRow struct {
|
||||||
LocalEvento pgtype.Text `json:"local_evento"`
|
LocalEvento pgtype.Text `json:"local_evento"`
|
||||||
Endereco pgtype.Text `json:"endereco"`
|
Endereco pgtype.Text `json:"endereco"`
|
||||||
Horario pgtype.Text `json:"horario"`
|
Horario pgtype.Text `json:"horario"`
|
||||||
|
HorarioFim pgtype.Text `json:"horario_fim"`
|
||||||
QtdFormandos pgtype.Int4 `json:"qtd_formandos"`
|
QtdFormandos pgtype.Int4 `json:"qtd_formandos"`
|
||||||
QtdFotografos pgtype.Int4 `json:"qtd_fotografos"`
|
QtdFotografos pgtype.Int4 `json:"qtd_fotografos"`
|
||||||
QtdRecepcionistas pgtype.Int4 `json:"qtd_recepcionistas"`
|
QtdRecepcionistas pgtype.Int4 `json:"qtd_recepcionistas"`
|
||||||
|
|
@ -814,6 +831,7 @@ func (q *Queries) ListAgendasByFot(ctx context.Context, arg ListAgendasByFotPara
|
||||||
&i.LocalEvento,
|
&i.LocalEvento,
|
||||||
&i.Endereco,
|
&i.Endereco,
|
||||||
&i.Horario,
|
&i.Horario,
|
||||||
|
&i.HorarioFim,
|
||||||
&i.QtdFormandos,
|
&i.QtdFormandos,
|
||||||
&i.QtdFotografos,
|
&i.QtdFotografos,
|
||||||
&i.QtdRecepcionistas,
|
&i.QtdRecepcionistas,
|
||||||
|
|
@ -850,7 +868,7 @@ func (q *Queries) ListAgendasByFot(ctx context.Context, arg ListAgendasByFotPara
|
||||||
|
|
||||||
const listAgendasByUser = `-- name: ListAgendasByUser :many
|
const listAgendasByUser = `-- name: ListAgendasByUser :many
|
||||||
SELECT
|
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.logistica_notificacao_enviada_em, a.regiao, a.contatos,
|
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.horario_fim, 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, a.regiao, a.contatos,
|
||||||
cf.fot as fot_numero,
|
cf.fot as fot_numero,
|
||||||
cf.instituicao,
|
cf.instituicao,
|
||||||
c.nome as curso_nome,
|
c.nome as curso_nome,
|
||||||
|
|
@ -898,6 +916,7 @@ type ListAgendasByUserRow struct {
|
||||||
LocalEvento pgtype.Text `json:"local_evento"`
|
LocalEvento pgtype.Text `json:"local_evento"`
|
||||||
Endereco pgtype.Text `json:"endereco"`
|
Endereco pgtype.Text `json:"endereco"`
|
||||||
Horario pgtype.Text `json:"horario"`
|
Horario pgtype.Text `json:"horario"`
|
||||||
|
HorarioFim pgtype.Text `json:"horario_fim"`
|
||||||
QtdFormandos pgtype.Int4 `json:"qtd_formandos"`
|
QtdFormandos pgtype.Int4 `json:"qtd_formandos"`
|
||||||
QtdFotografos pgtype.Int4 `json:"qtd_fotografos"`
|
QtdFotografos pgtype.Int4 `json:"qtd_fotografos"`
|
||||||
QtdRecepcionistas pgtype.Int4 `json:"qtd_recepcionistas"`
|
QtdRecepcionistas pgtype.Int4 `json:"qtd_recepcionistas"`
|
||||||
|
|
@ -952,6 +971,7 @@ func (q *Queries) ListAgendasByUser(ctx context.Context, arg ListAgendasByUserPa
|
||||||
&i.LocalEvento,
|
&i.LocalEvento,
|
||||||
&i.Endereco,
|
&i.Endereco,
|
||||||
&i.Horario,
|
&i.Horario,
|
||||||
|
&i.HorarioFim,
|
||||||
&i.QtdFormandos,
|
&i.QtdFormandos,
|
||||||
&i.QtdFotografos,
|
&i.QtdFotografos,
|
||||||
&i.QtdRecepcionistas,
|
&i.QtdRecepcionistas,
|
||||||
|
|
@ -1166,26 +1186,27 @@ SET
|
||||||
local_evento = $6,
|
local_evento = $6,
|
||||||
endereco = $7,
|
endereco = $7,
|
||||||
horario = $8,
|
horario = $8,
|
||||||
qtd_formandos = $9,
|
horario_fim = $9,
|
||||||
qtd_fotografos = $10,
|
qtd_formandos = $10,
|
||||||
qtd_recepcionistas = $11,
|
qtd_fotografos = $11,
|
||||||
qtd_cinegrafistas = $12,
|
qtd_recepcionistas = $12,
|
||||||
qtd_estudios = $13,
|
qtd_cinegrafistas = $13,
|
||||||
qtd_ponto_foto = $14,
|
qtd_estudios = $14,
|
||||||
qtd_ponto_id = $15,
|
qtd_ponto_foto = $15,
|
||||||
qtd_ponto_decorado = $16,
|
qtd_ponto_id = $16,
|
||||||
qtd_pontos_led = $17,
|
qtd_ponto_decorado = $17,
|
||||||
qtd_plataforma_360 = $18,
|
qtd_pontos_led = $18,
|
||||||
status_profissionais = $19,
|
qtd_plataforma_360 = $19,
|
||||||
foto_faltante = $20,
|
status_profissionais = $20,
|
||||||
recep_faltante = $21,
|
foto_faltante = $21,
|
||||||
cine_faltante = $22,
|
recep_faltante = $22,
|
||||||
logistica_observacoes = $23,
|
cine_faltante = $23,
|
||||||
pre_venda = $24,
|
logistica_observacoes = $24,
|
||||||
contatos = $25,
|
pre_venda = $25,
|
||||||
|
contatos = $26,
|
||||||
atualizado_em = NOW()
|
atualizado_em = NOW()
|
||||||
WHERE id = $1 AND regiao = $26
|
WHERE id = $1 AND regiao = $27
|
||||||
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, regiao, contatos
|
RETURNING id, user_id, fot_id, data_evento, tipo_evento_id, observacoes_evento, local_evento, endereco, horario, horario_fim, 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, regiao, contatos
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateAgendaParams struct {
|
type UpdateAgendaParams struct {
|
||||||
|
|
@ -1197,6 +1218,7 @@ type UpdateAgendaParams struct {
|
||||||
LocalEvento pgtype.Text `json:"local_evento"`
|
LocalEvento pgtype.Text `json:"local_evento"`
|
||||||
Endereco pgtype.Text `json:"endereco"`
|
Endereco pgtype.Text `json:"endereco"`
|
||||||
Horario pgtype.Text `json:"horario"`
|
Horario pgtype.Text `json:"horario"`
|
||||||
|
HorarioFim pgtype.Text `json:"horario_fim"`
|
||||||
QtdFormandos pgtype.Int4 `json:"qtd_formandos"`
|
QtdFormandos pgtype.Int4 `json:"qtd_formandos"`
|
||||||
QtdFotografos pgtype.Int4 `json:"qtd_fotografos"`
|
QtdFotografos pgtype.Int4 `json:"qtd_fotografos"`
|
||||||
QtdRecepcionistas pgtype.Int4 `json:"qtd_recepcionistas"`
|
QtdRecepcionistas pgtype.Int4 `json:"qtd_recepcionistas"`
|
||||||
|
|
@ -1227,6 +1249,7 @@ func (q *Queries) UpdateAgenda(ctx context.Context, arg UpdateAgendaParams) (Age
|
||||||
arg.LocalEvento,
|
arg.LocalEvento,
|
||||||
arg.Endereco,
|
arg.Endereco,
|
||||||
arg.Horario,
|
arg.Horario,
|
||||||
|
arg.HorarioFim,
|
||||||
arg.QtdFormandos,
|
arg.QtdFormandos,
|
||||||
arg.QtdFotografos,
|
arg.QtdFotografos,
|
||||||
arg.QtdRecepcionistas,
|
arg.QtdRecepcionistas,
|
||||||
|
|
@ -1257,6 +1280,7 @@ func (q *Queries) UpdateAgenda(ctx context.Context, arg UpdateAgendaParams) (Age
|
||||||
&i.LocalEvento,
|
&i.LocalEvento,
|
||||||
&i.Endereco,
|
&i.Endereco,
|
||||||
&i.Horario,
|
&i.Horario,
|
||||||
|
&i.HorarioFim,
|
||||||
&i.QtdFormandos,
|
&i.QtdFormandos,
|
||||||
&i.QtdFotografos,
|
&i.QtdFotografos,
|
||||||
&i.QtdRecepcionistas,
|
&i.QtdRecepcionistas,
|
||||||
|
|
@ -1287,7 +1311,7 @@ const updateAgendaStatus = `-- name: UpdateAgendaStatus :one
|
||||||
UPDATE agenda
|
UPDATE agenda
|
||||||
SET status = $2, atualizado_em = NOW()
|
SET status = $2, atualizado_em = NOW()
|
||||||
WHERE id = $1 AND regiao = $3
|
WHERE id = $1 AND regiao = $3
|
||||||
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, regiao, contatos
|
RETURNING id, user_id, fot_id, data_evento, tipo_evento_id, observacoes_evento, local_evento, endereco, horario, horario_fim, 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, regiao, contatos
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateAgendaStatusParams struct {
|
type UpdateAgendaStatusParams struct {
|
||||||
|
|
@ -1309,6 +1333,7 @@ func (q *Queries) UpdateAgendaStatus(ctx context.Context, arg UpdateAgendaStatus
|
||||||
&i.LocalEvento,
|
&i.LocalEvento,
|
||||||
&i.Endereco,
|
&i.Endereco,
|
||||||
&i.Horario,
|
&i.Horario,
|
||||||
|
&i.HorarioFim,
|
||||||
&i.QtdFormandos,
|
&i.QtdFormandos,
|
||||||
&i.QtdFotografos,
|
&i.QtdFotografos,
|
||||||
&i.QtdRecepcionistas,
|
&i.QtdRecepcionistas,
|
||||||
|
|
|
||||||
188
backend/internal/db/generated/convites_diarios.sql.go
Normal file
188
backend/internal/db/generated/convites_diarios.sql.go
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.30.0
|
||||||
|
// source: convites_diarios.sql
|
||||||
|
|
||||||
|
package generated
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
const createConviteDiario = `-- name: CreateConviteDiario :one
|
||||||
|
INSERT INTO convites_diarios (
|
||||||
|
profissional_id, data, status, regiao
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4
|
||||||
|
)
|
||||||
|
RETURNING id, profissional_id, data, status, motivo_rejeicao, regiao, criado_em, atualizado_em
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateConviteDiarioParams struct {
|
||||||
|
ProfissionalID pgtype.UUID `json:"profissional_id"`
|
||||||
|
Data pgtype.Date `json:"data"`
|
||||||
|
Status pgtype.Text `json:"status"`
|
||||||
|
Regiao pgtype.Text `json:"regiao"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateConviteDiario(ctx context.Context, arg CreateConviteDiarioParams) (ConvitesDiario, error) {
|
||||||
|
row := q.db.QueryRow(ctx, createConviteDiario,
|
||||||
|
arg.ProfissionalID,
|
||||||
|
arg.Data,
|
||||||
|
arg.Status,
|
||||||
|
arg.Regiao,
|
||||||
|
)
|
||||||
|
var i ConvitesDiario
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProfissionalID,
|
||||||
|
&i.Data,
|
||||||
|
&i.Status,
|
||||||
|
&i.MotivoRejeicao,
|
||||||
|
&i.Regiao,
|
||||||
|
&i.CriadoEm,
|
||||||
|
&i.AtualizadoEm,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteConvite = `-- name: DeleteConvite :exec
|
||||||
|
DELETE FROM convites_diarios
|
||||||
|
WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) DeleteConvite(ctx context.Context, id pgtype.UUID) error {
|
||||||
|
_, err := q.db.Exec(ctx, deleteConvite, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getConviteByProfissionalAndDate = `-- name: GetConviteByProfissionalAndDate :one
|
||||||
|
SELECT id, profissional_id, data, status, motivo_rejeicao, regiao, criado_em, atualizado_em FROM convites_diarios
|
||||||
|
WHERE profissional_id = $1 AND data = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetConviteByProfissionalAndDateParams struct {
|
||||||
|
ProfissionalID pgtype.UUID `json:"profissional_id"`
|
||||||
|
Data pgtype.Date `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetConviteByProfissionalAndDate(ctx context.Context, arg GetConviteByProfissionalAndDateParams) (ConvitesDiario, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getConviteByProfissionalAndDate, arg.ProfissionalID, arg.Data)
|
||||||
|
var i ConvitesDiario
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProfissionalID,
|
||||||
|
&i.Data,
|
||||||
|
&i.Status,
|
||||||
|
&i.MotivoRejeicao,
|
||||||
|
&i.Regiao,
|
||||||
|
&i.CriadoEm,
|
||||||
|
&i.AtualizadoEm,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getConvitesByDateAndRegion = `-- name: GetConvitesByDateAndRegion :many
|
||||||
|
SELECT id, profissional_id, data, status, motivo_rejeicao, regiao, criado_em, atualizado_em FROM convites_diarios
|
||||||
|
WHERE data = $1 AND regiao = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetConvitesByDateAndRegionParams struct {
|
||||||
|
Data pgtype.Date `json:"data"`
|
||||||
|
Regiao pgtype.Text `json:"regiao"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetConvitesByDateAndRegion(ctx context.Context, arg GetConvitesByDateAndRegionParams) ([]ConvitesDiario, error) {
|
||||||
|
rows, err := q.db.Query(ctx, getConvitesByDateAndRegion, arg.Data, arg.Regiao)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ConvitesDiario
|
||||||
|
for rows.Next() {
|
||||||
|
var i ConvitesDiario
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProfissionalID,
|
||||||
|
&i.Data,
|
||||||
|
&i.Status,
|
||||||
|
&i.MotivoRejeicao,
|
||||||
|
&i.Regiao,
|
||||||
|
&i.CriadoEm,
|
||||||
|
&i.AtualizadoEm,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const getConvitesByProfissional = `-- name: GetConvitesByProfissional :many
|
||||||
|
SELECT id, profissional_id, data, status, motivo_rejeicao, regiao, criado_em, atualizado_em FROM convites_diarios
|
||||||
|
WHERE profissional_id = $1
|
||||||
|
ORDER BY data ASC
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetConvitesByProfissional(ctx context.Context, profissionalID pgtype.UUID) ([]ConvitesDiario, error) {
|
||||||
|
rows, err := q.db.Query(ctx, getConvitesByProfissional, profissionalID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ConvitesDiario
|
||||||
|
for rows.Next() {
|
||||||
|
var i ConvitesDiario
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProfissionalID,
|
||||||
|
&i.Data,
|
||||||
|
&i.Status,
|
||||||
|
&i.MotivoRejeicao,
|
||||||
|
&i.Regiao,
|
||||||
|
&i.CriadoEm,
|
||||||
|
&i.AtualizadoEm,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateConviteStatus = `-- name: UpdateConviteStatus :one
|
||||||
|
UPDATE convites_diarios
|
||||||
|
SET status = $2, motivo_rejeicao = $3, atualizado_em = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id, profissional_id, data, status, motivo_rejeicao, regiao, criado_em, atualizado_em
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateConviteStatusParams struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
Status pgtype.Text `json:"status"`
|
||||||
|
MotivoRejeicao pgtype.Text `json:"motivo_rejeicao"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateConviteStatus(ctx context.Context, arg UpdateConviteStatusParams) (ConvitesDiario, error) {
|
||||||
|
row := q.db.QueryRow(ctx, updateConviteStatus, arg.ID, arg.Status, arg.MotivoRejeicao)
|
||||||
|
var i ConvitesDiario
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProfissionalID,
|
||||||
|
&i.Data,
|
||||||
|
&i.Status,
|
||||||
|
&i.MotivoRejeicao,
|
||||||
|
&i.Regiao,
|
||||||
|
&i.CriadoEm,
|
||||||
|
&i.AtualizadoEm,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
164
backend/internal/db/generated/logistica_disponiveis.sql.go
Normal file
164
backend/internal/db/generated/logistica_disponiveis.sql.go
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.30.0
|
||||||
|
// source: logistica_disponiveis.sql
|
||||||
|
|
||||||
|
package generated
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
const countProfissionaisDisponiveisLogistica = `-- name: CountProfissionaisDisponiveisLogistica :one
|
||||||
|
SELECT COUNT(p.id)
|
||||||
|
FROM cadastro_profissionais p
|
||||||
|
WHERE p.regiao = $1
|
||||||
|
AND ($2::text = '' OR p.nome ILIKE '%' || $2 || '%')
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM convites_diarios cd
|
||||||
|
WHERE cd.profissional_id = p.id
|
||||||
|
AND cd.data = CAST($3 AS DATE)
|
||||||
|
AND cd.status IN ('PENDENTE', 'ACEITO')
|
||||||
|
)
|
||||||
|
`
|
||||||
|
|
||||||
|
type CountProfissionaisDisponiveisLogisticaParams struct {
|
||||||
|
Regiao pgtype.Text `json:"regiao"`
|
||||||
|
Column2 string `json:"column_2"`
|
||||||
|
Column3 pgtype.Date `json:"column_3"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CountProfissionaisDisponiveisLogistica(ctx context.Context, arg CountProfissionaisDisponiveisLogisticaParams) (int64, error) {
|
||||||
|
row := q.db.QueryRow(ctx, countProfissionaisDisponiveisLogistica, arg.Regiao, arg.Column2, arg.Column3)
|
||||||
|
var count int64
|
||||||
|
err := row.Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getProfissionaisDisponiveisLogistica = `-- name: GetProfissionaisDisponiveisLogistica :many
|
||||||
|
SELECT p.id, p.usuario_id, p.nome, p.funcao_profissional_id, p.endereco, p.cidade, p.uf, p.whatsapp, p.cep, p.numero, p.complemento, p.bairro, p.cpf_cnpj_titular, p.banco, p.agencia, p.conta, p.conta_pix, p.carro_disponivel, p.tem_estudio, p.qtd_estudio, p.tipo_cartao, p.observacao, p.qual_tec, p.educacao_simpatia, p.desempenho_evento, p.disp_horario, p.media, p.tabela_free, p.extra_por_equipamento, p.equipamentos, p.email, p.avatar_url, p.criado_em, p.atualizado_em, p.regiao, f.nome as funcao_nome
|
||||||
|
FROM cadastro_profissionais p
|
||||||
|
LEFT JOIN funcoes_profissionais f ON p.funcao_profissional_id = f.id
|
||||||
|
WHERE p.regiao = $1
|
||||||
|
AND ($2::text = '' OR p.nome ILIKE '%' || $2 || '%')
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM convites_diarios cd
|
||||||
|
WHERE cd.profissional_id = p.id
|
||||||
|
AND cd.data = CAST($3 AS DATE)
|
||||||
|
AND cd.status IN ('PENDENTE', 'ACEITO')
|
||||||
|
)
|
||||||
|
ORDER BY p.nome ASC
|
||||||
|
LIMIT $4 OFFSET $5
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetProfissionaisDisponiveisLogisticaParams struct {
|
||||||
|
Regiao pgtype.Text `json:"regiao"`
|
||||||
|
Column2 string `json:"column_2"`
|
||||||
|
Column3 pgtype.Date `json:"column_3"`
|
||||||
|
Limit int32 `json:"limit"`
|
||||||
|
Offset int32 `json:"offset"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetProfissionaisDisponiveisLogisticaRow struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
UsuarioID pgtype.UUID `json:"usuario_id"`
|
||||||
|
Nome string `json:"nome"`
|
||||||
|
FuncaoProfissionalID pgtype.UUID `json:"funcao_profissional_id"`
|
||||||
|
Endereco pgtype.Text `json:"endereco"`
|
||||||
|
Cidade pgtype.Text `json:"cidade"`
|
||||||
|
Uf pgtype.Text `json:"uf"`
|
||||||
|
Whatsapp pgtype.Text `json:"whatsapp"`
|
||||||
|
Cep pgtype.Text `json:"cep"`
|
||||||
|
Numero pgtype.Text `json:"numero"`
|
||||||
|
Complemento pgtype.Text `json:"complemento"`
|
||||||
|
Bairro pgtype.Text `json:"bairro"`
|
||||||
|
CpfCnpjTitular pgtype.Text `json:"cpf_cnpj_titular"`
|
||||||
|
Banco pgtype.Text `json:"banco"`
|
||||||
|
Agencia pgtype.Text `json:"agencia"`
|
||||||
|
Conta pgtype.Text `json:"conta"`
|
||||||
|
ContaPix pgtype.Text `json:"conta_pix"`
|
||||||
|
CarroDisponivel pgtype.Bool `json:"carro_disponivel"`
|
||||||
|
TemEstudio pgtype.Bool `json:"tem_estudio"`
|
||||||
|
QtdEstudio pgtype.Int4 `json:"qtd_estudio"`
|
||||||
|
TipoCartao pgtype.Text `json:"tipo_cartao"`
|
||||||
|
Observacao pgtype.Text `json:"observacao"`
|
||||||
|
QualTec pgtype.Int4 `json:"qual_tec"`
|
||||||
|
EducacaoSimpatia pgtype.Int4 `json:"educacao_simpatia"`
|
||||||
|
DesempenhoEvento pgtype.Int4 `json:"desempenho_evento"`
|
||||||
|
DispHorario pgtype.Int4 `json:"disp_horario"`
|
||||||
|
Media pgtype.Numeric `json:"media"`
|
||||||
|
TabelaFree pgtype.Text `json:"tabela_free"`
|
||||||
|
ExtraPorEquipamento pgtype.Bool `json:"extra_por_equipamento"`
|
||||||
|
Equipamentos pgtype.Text `json:"equipamentos"`
|
||||||
|
Email pgtype.Text `json:"email"`
|
||||||
|
AvatarUrl pgtype.Text `json:"avatar_url"`
|
||||||
|
CriadoEm pgtype.Timestamptz `json:"criado_em"`
|
||||||
|
AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"`
|
||||||
|
Regiao pgtype.Text `json:"regiao"`
|
||||||
|
FuncaoNome pgtype.Text `json:"funcao_nome"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetProfissionaisDisponiveisLogistica(ctx context.Context, arg GetProfissionaisDisponiveisLogisticaParams) ([]GetProfissionaisDisponiveisLogisticaRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, getProfissionaisDisponiveisLogistica,
|
||||||
|
arg.Regiao,
|
||||||
|
arg.Column2,
|
||||||
|
arg.Column3,
|
||||||
|
arg.Limit,
|
||||||
|
arg.Offset,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []GetProfissionaisDisponiveisLogisticaRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i GetProfissionaisDisponiveisLogisticaRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.UsuarioID,
|
||||||
|
&i.Nome,
|
||||||
|
&i.FuncaoProfissionalID,
|
||||||
|
&i.Endereco,
|
||||||
|
&i.Cidade,
|
||||||
|
&i.Uf,
|
||||||
|
&i.Whatsapp,
|
||||||
|
&i.Cep,
|
||||||
|
&i.Numero,
|
||||||
|
&i.Complemento,
|
||||||
|
&i.Bairro,
|
||||||
|
&i.CpfCnpjTitular,
|
||||||
|
&i.Banco,
|
||||||
|
&i.Agencia,
|
||||||
|
&i.Conta,
|
||||||
|
&i.ContaPix,
|
||||||
|
&i.CarroDisponivel,
|
||||||
|
&i.TemEstudio,
|
||||||
|
&i.QtdEstudio,
|
||||||
|
&i.TipoCartao,
|
||||||
|
&i.Observacao,
|
||||||
|
&i.QualTec,
|
||||||
|
&i.EducacaoSimpatia,
|
||||||
|
&i.DesempenhoEvento,
|
||||||
|
&i.DispHorario,
|
||||||
|
&i.Media,
|
||||||
|
&i.TabelaFree,
|
||||||
|
&i.ExtraPorEquipamento,
|
||||||
|
&i.Equipamentos,
|
||||||
|
&i.Email,
|
||||||
|
&i.AvatarUrl,
|
||||||
|
&i.CriadoEm,
|
||||||
|
&i.AtualizadoEm,
|
||||||
|
&i.Regiao,
|
||||||
|
&i.FuncaoNome,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@ type Agenda struct {
|
||||||
LocalEvento pgtype.Text `json:"local_evento"`
|
LocalEvento pgtype.Text `json:"local_evento"`
|
||||||
Endereco pgtype.Text `json:"endereco"`
|
Endereco pgtype.Text `json:"endereco"`
|
||||||
Horario pgtype.Text `json:"horario"`
|
Horario pgtype.Text `json:"horario"`
|
||||||
|
HorarioFim pgtype.Text `json:"horario_fim"`
|
||||||
QtdFormandos pgtype.Int4 `json:"qtd_formandos"`
|
QtdFormandos pgtype.Int4 `json:"qtd_formandos"`
|
||||||
QtdFotografos pgtype.Int4 `json:"qtd_fotografos"`
|
QtdFotografos pgtype.Int4 `json:"qtd_fotografos"`
|
||||||
QtdRecepcionistas pgtype.Int4 `json:"qtd_recepcionistas"`
|
QtdRecepcionistas pgtype.Int4 `json:"qtd_recepcionistas"`
|
||||||
|
|
@ -159,6 +160,17 @@ type CodigosAcesso struct {
|
||||||
Regiao pgtype.Text `json:"regiao"`
|
Regiao pgtype.Text `json:"regiao"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ConvitesDiario struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
ProfissionalID pgtype.UUID `json:"profissional_id"`
|
||||||
|
Data pgtype.Date `json:"data"`
|
||||||
|
Status pgtype.Text `json:"status"`
|
||||||
|
MotivoRejeicao pgtype.Text `json:"motivo_rejeicao"`
|
||||||
|
Regiao pgtype.Text `json:"regiao"`
|
||||||
|
CriadoEm pgtype.Timestamptz `json:"criado_em"`
|
||||||
|
AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"`
|
||||||
|
}
|
||||||
|
|
||||||
type Curso struct {
|
type Curso struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
Nome string `json:"nome"`
|
Nome string `json:"nome"`
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE IF EXISTS convites_diarios CASCADE;
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS convites_diarios (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
profissional_id UUID NOT NULL REFERENCES cadastro_profissionais(id) ON DELETE CASCADE,
|
||||||
|
data DATE NOT NULL,
|
||||||
|
status VARCHAR(20) DEFAULT 'PENDENTE', -- PENDENTE, ACEITO, REJEITADO
|
||||||
|
motivo_rejeicao TEXT,
|
||||||
|
regiao CHAR(2) DEFAULT 'SP',
|
||||||
|
criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
atualizado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE(profissional_id, data)
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE agenda ADD COLUMN IF NOT EXISTS horario_fim VARCHAR(20);
|
||||||
|
|
@ -7,6 +7,7 @@ INSERT INTO agenda (
|
||||||
local_evento,
|
local_evento,
|
||||||
endereco,
|
endereco,
|
||||||
horario,
|
horario,
|
||||||
|
horario_fim,
|
||||||
qtd_formandos,
|
qtd_formandos,
|
||||||
qtd_fotografos,
|
qtd_fotografos,
|
||||||
qtd_recepcionistas,
|
qtd_recepcionistas,
|
||||||
|
|
@ -27,7 +28,7 @@ INSERT INTO agenda (
|
||||||
regiao,
|
regiao,
|
||||||
contatos
|
contatos
|
||||||
) VALUES (
|
) 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, @regiao, $25
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, @regiao, $26
|
||||||
) RETURNING *;
|
) RETURNING *;
|
||||||
|
|
||||||
-- name: GetAgenda :one
|
-- name: GetAgenda :one
|
||||||
|
|
@ -112,23 +113,24 @@ SET
|
||||||
local_evento = $6,
|
local_evento = $6,
|
||||||
endereco = $7,
|
endereco = $7,
|
||||||
horario = $8,
|
horario = $8,
|
||||||
qtd_formandos = $9,
|
horario_fim = $9,
|
||||||
qtd_fotografos = $10,
|
qtd_formandos = $10,
|
||||||
qtd_recepcionistas = $11,
|
qtd_fotografos = $11,
|
||||||
qtd_cinegrafistas = $12,
|
qtd_recepcionistas = $12,
|
||||||
qtd_estudios = $13,
|
qtd_cinegrafistas = $13,
|
||||||
qtd_ponto_foto = $14,
|
qtd_estudios = $14,
|
||||||
qtd_ponto_id = $15,
|
qtd_ponto_foto = $15,
|
||||||
qtd_ponto_decorado = $16,
|
qtd_ponto_id = $16,
|
||||||
qtd_pontos_led = $17,
|
qtd_ponto_decorado = $17,
|
||||||
qtd_plataforma_360 = $18,
|
qtd_pontos_led = $18,
|
||||||
status_profissionais = $19,
|
qtd_plataforma_360 = $19,
|
||||||
foto_faltante = $20,
|
status_profissionais = $20,
|
||||||
recep_faltante = $21,
|
foto_faltante = $21,
|
||||||
cine_faltante = $22,
|
recep_faltante = $22,
|
||||||
logistica_observacoes = $23,
|
cine_faltante = $23,
|
||||||
pre_venda = $24,
|
logistica_observacoes = $24,
|
||||||
contatos = $25,
|
pre_venda = $25,
|
||||||
|
contatos = $26,
|
||||||
atualizado_em = NOW()
|
atualizado_em = NOW()
|
||||||
WHERE id = $1 AND regiao = @regiao
|
WHERE id = $1 AND regiao = @regiao
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
@ -196,7 +198,7 @@ WHERE dp.data = $1
|
||||||
ORDER BY p.nome;
|
ORDER BY p.nome;
|
||||||
|
|
||||||
-- name: CheckProfessionalBusyDate :many
|
-- name: CheckProfessionalBusyDate :many
|
||||||
SELECT a.id, a.horario, ap.status
|
SELECT a.id, a.horario, a.horario_fim, ap.status
|
||||||
FROM agenda_profissionais ap
|
FROM agenda_profissionais ap
|
||||||
JOIN agenda a ON ap.agenda_id = a.id
|
JOIN agenda a ON ap.agenda_id = a.id
|
||||||
WHERE ap.profissional_id = $1
|
WHERE ap.profissional_id = $1
|
||||||
|
|
|
||||||
30
backend/internal/db/queries/convites_diarios.sql
Normal file
30
backend/internal/db/queries/convites_diarios.sql
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
-- name: CreateConviteDiario :one
|
||||||
|
INSERT INTO convites_diarios (
|
||||||
|
profissional_id, data, status, regiao
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4
|
||||||
|
)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: GetConvitesByProfissional :many
|
||||||
|
SELECT * FROM convites_diarios
|
||||||
|
WHERE profissional_id = $1
|
||||||
|
ORDER BY data ASC;
|
||||||
|
|
||||||
|
-- name: GetConvitesByDateAndRegion :many
|
||||||
|
SELECT * FROM convites_diarios
|
||||||
|
WHERE data = $1 AND regiao = $2;
|
||||||
|
|
||||||
|
-- name: GetConviteByProfissionalAndDate :one
|
||||||
|
SELECT * FROM convites_diarios
|
||||||
|
WHERE profissional_id = $1 AND data = $2;
|
||||||
|
|
||||||
|
-- name: UpdateConviteStatus :one
|
||||||
|
UPDATE convites_diarios
|
||||||
|
SET status = $2, motivo_rejeicao = $3, atualizado_em = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: DeleteConvite :exec
|
||||||
|
DELETE FROM convites_diarios
|
||||||
|
WHERE id = $1;
|
||||||
26
backend/internal/db/queries/logistica_disponiveis.sql
Normal file
26
backend/internal/db/queries/logistica_disponiveis.sql
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
-- name: GetProfissionaisDisponiveisLogistica :many
|
||||||
|
SELECT p.*, f.nome as funcao_nome
|
||||||
|
FROM cadastro_profissionais p
|
||||||
|
LEFT JOIN funcoes_profissionais f ON p.funcao_profissional_id = f.id
|
||||||
|
WHERE p.regiao = $1
|
||||||
|
AND ($2::text = '' OR p.nome ILIKE '%' || $2 || '%')
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM convites_diarios cd
|
||||||
|
WHERE cd.profissional_id = p.id
|
||||||
|
AND cd.data = CAST($3 AS DATE)
|
||||||
|
AND cd.status IN ('PENDENTE', 'ACEITO')
|
||||||
|
)
|
||||||
|
ORDER BY p.nome ASC
|
||||||
|
LIMIT $4 OFFSET $5;
|
||||||
|
|
||||||
|
-- name: CountProfissionaisDisponiveisLogistica :one
|
||||||
|
SELECT COUNT(p.id)
|
||||||
|
FROM cadastro_profissionais p
|
||||||
|
WHERE p.regiao = $1
|
||||||
|
AND ($2::text = '' OR p.nome ILIKE '%' || $2 || '%')
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM convites_diarios cd
|
||||||
|
WHERE cd.profissional_id = p.id
|
||||||
|
AND cd.data = CAST($3 AS DATE)
|
||||||
|
AND cd.status IN ('PENDENTE', 'ACEITO')
|
||||||
|
);
|
||||||
|
|
@ -226,6 +226,7 @@ CREATE TABLE IF NOT EXISTS agenda (
|
||||||
local_evento VARCHAR(255),
|
local_evento VARCHAR(255),
|
||||||
endereco VARCHAR(255),
|
endereco VARCHAR(255),
|
||||||
horario VARCHAR(20),
|
horario VARCHAR(20),
|
||||||
|
horario_fim VARCHAR(20),
|
||||||
qtd_formandos INTEGER DEFAULT 0,
|
qtd_formandos INTEGER DEFAULT 0,
|
||||||
qtd_fotografos INTEGER DEFAULT 0,
|
qtd_fotografos INTEGER DEFAULT 0,
|
||||||
qtd_recepcionistas INTEGER DEFAULT 0,
|
qtd_recepcionistas INTEGER DEFAULT 0,
|
||||||
|
|
@ -262,6 +263,18 @@ CREATE TABLE IF NOT EXISTS agenda_profissionais (
|
||||||
UNIQUE(agenda_id, profissional_id)
|
UNIQUE(agenda_id, profissional_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS convites_diarios (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
profissional_id UUID NOT NULL REFERENCES cadastro_profissionais(id) ON DELETE CASCADE,
|
||||||
|
data DATE NOT NULL,
|
||||||
|
status VARCHAR(20) DEFAULT 'PENDENTE', -- PENDENTE, ACEITO, REJEITADO
|
||||||
|
motivo_rejeicao TEXT,
|
||||||
|
regiao CHAR(2) DEFAULT 'SP',
|
||||||
|
criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
atualizado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE(profissional_id, data)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS disponibilidade_profissionais (
|
CREATE TABLE IF NOT EXISTS disponibilidade_profissionais (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
usuario_id UUID NOT NULL REFERENCES usuarios(id) ON DELETE CASCADE,
|
usuario_id UUID NOT NULL REFERENCES usuarios(id) ON DELETE CASCADE,
|
||||||
|
|
@ -530,3 +543,6 @@ ALTER TABLE agenda_profissionais ADD COLUMN IF NOT EXISTS is_coordinator BOOLEAN
|
||||||
|
|
||||||
-- Migration 020: Add Finalizada Column to FOT
|
-- Migration 020: Add Finalizada Column to FOT
|
||||||
ALTER TABLE cadastro_fot ADD COLUMN IF NOT EXISTS finalizada BOOLEAN DEFAULT FALSE;
|
ALTER TABLE cadastro_fot ADD COLUMN IF NOT EXISTS finalizada BOOLEAN DEFAULT FALSE;
|
||||||
|
|
||||||
|
-- Migration 021: Add Horario Fim
|
||||||
|
ALTER TABLE agenda ADD COLUMN IF NOT EXISTS horario_fim VARCHAR(20);
|
||||||
|
|
|
||||||
|
|
@ -162,3 +162,155 @@ func (h *Handler) ListPassengers(c *gin.Context) {
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, resp)
|
c.JSON(http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- LOGISTICA DAILY INVITATIONS & PROFESSIONALS LIST ---- //
|
||||||
|
|
||||||
|
func (h *Handler) ListProfissionaisDisponiveis(c *gin.Context) {
|
||||||
|
var query ListDisponiveisInput
|
||||||
|
if err := c.ShouldBindQuery(&query); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
regiao := c.GetString("regiao")
|
||||||
|
res, err := h.service.ListProfissionaisDisponiveisLogistica(c.Request.Context(), query, regiao)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
formatted := make([]map[string]interface{}, len(res.Data))
|
||||||
|
for i, r := range res.Data {
|
||||||
|
formatted[i] = map[string]interface{}{
|
||||||
|
"id": uuid.UUID(r.ID.Bytes).String(),
|
||||||
|
"nome": r.Nome,
|
||||||
|
"role": r.FuncaoNome.String,
|
||||||
|
"avatar_url": r.AvatarUrl.String,
|
||||||
|
"whatsapp": r.Whatsapp.String,
|
||||||
|
"cidade": r.Cidade.String,
|
||||||
|
"carro_disponivel": r.CarroDisponivel.Bool,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"data": formatted,
|
||||||
|
"total": res.Total,
|
||||||
|
"page": res.Page,
|
||||||
|
"total_pages": res.TotalPages,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CreateConvite(c *gin.Context) {
|
||||||
|
var req CreateConviteInput
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
regiao := c.GetString("regiao")
|
||||||
|
convite, err := h.service.CreateConviteDiario(c.Request.Context(), req, regiao)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"id": uuid.UUID(convite.ID.Bytes).String()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Invitations for logged professional
|
||||||
|
func (h *Handler) ListConvitesProfissional(c *gin.Context) {
|
||||||
|
// For MVP: Let the frontend send the prof ID or use a dedicated endpoint where frontend provides it.
|
||||||
|
// Actually frontend can provide ?prof_id=...
|
||||||
|
|
||||||
|
pID := c.Query("prof_id")
|
||||||
|
if pID == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "prof_id is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
convites, err := h.service.ListConvitesDoProfissional(c.Request.Context(), pID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format response nicely
|
||||||
|
resp := make([]map[string]interface{}, len(convites))
|
||||||
|
for i, c := range convites {
|
||||||
|
// convert time.Time to YYYY-MM-DD
|
||||||
|
var dateStr string
|
||||||
|
if c.Data.Valid {
|
||||||
|
dateStr = c.Data.Time.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp[i] = map[string]interface{}{
|
||||||
|
"id": uuid.UUID(c.ID.Bytes).String(),
|
||||||
|
"profissional_id": uuid.UUID(c.ProfissionalID.Bytes).String(),
|
||||||
|
"data": dateStr,
|
||||||
|
"status": c.Status.String,
|
||||||
|
"motivo_rejeicao": c.MotivoRejeicao.String,
|
||||||
|
"criado_em": c.CriadoEm.Time.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Respond to an invitation
|
||||||
|
func (h *Handler) ResponderConvite(c *gin.Context) {
|
||||||
|
conviteID := c.Param("id")
|
||||||
|
if conviteID == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "id required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req ResponderConviteInput
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
convite, err := h.service.ResponderConvite(c.Request.Context(), conviteID, req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": convite.Status.String})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Invitations by Date for Logistics Manager
|
||||||
|
func (h *Handler) ListConvitesPorData(c *gin.Context) {
|
||||||
|
dataStr := c.Query("data")
|
||||||
|
if dataStr == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "data is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
regiao := c.GetString("regiao")
|
||||||
|
convites, err := h.service.ListConvitesPorData(c.Request.Context(), dataStr, regiao)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format response
|
||||||
|
resp := make([]map[string]interface{}, len(convites))
|
||||||
|
for i, conv := range convites {
|
||||||
|
var dateStr string
|
||||||
|
if conv.Data.Valid {
|
||||||
|
dateStr = conv.Data.Time.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp[i] = map[string]interface{}{
|
||||||
|
"id": uuid.UUID(conv.ID.Bytes).String(),
|
||||||
|
"profissional_id": uuid.UUID(conv.ProfissionalID.Bytes).String(),
|
||||||
|
"data": dateStr,
|
||||||
|
"status": conv.Status.String,
|
||||||
|
"motivo_rejeicao": conv.MotivoRejeicao.String,
|
||||||
|
"criado_em": conv.CriadoEm.Time.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ package logistica
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"photum-backend/internal/config"
|
"photum-backend/internal/config"
|
||||||
"photum-backend/internal/db/generated"
|
"photum-backend/internal/db/generated"
|
||||||
|
|
@ -248,3 +250,157 @@ func (s *Service) ListPassageiros(ctx context.Context, carroID string) ([]genera
|
||||||
}
|
}
|
||||||
return s.queries.ListPassageirosByCarroID(ctx, pgtype.UUID{Bytes: cID, Valid: true})
|
return s.queries.ListPassageirosByCarroID(ctx, pgtype.UUID{Bytes: cID, Valid: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- LOGISTICA DAILY INVITATIONS & PROFESSIONALS LIST ---- //
|
||||||
|
|
||||||
|
type ListDisponiveisInput struct {
|
||||||
|
Data string `form:"data" binding:"required"`
|
||||||
|
Search string `form:"search"`
|
||||||
|
Page int `form:"page,default=1"`
|
||||||
|
Limit int `form:"limit,default=50"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListDisponiveisResult struct {
|
||||||
|
Data []generated.GetProfissionaisDisponiveisLogisticaRow `json:"data"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
TotalPages int `json:"total_pages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListProfissionaisDisponiveisLogistica(ctx context.Context, input ListDisponiveisInput, regiao string) (*ListDisponiveisResult, error) {
|
||||||
|
offset := (input.Page - 1) * input.Limit
|
||||||
|
|
||||||
|
parsedDate, parsedDateErr := time.Parse("2006-01-02", input.Data)
|
||||||
|
|
||||||
|
arg := generated.GetProfissionaisDisponiveisLogisticaParams{
|
||||||
|
Regiao: pgtype.Text{String: regiao, Valid: true},
|
||||||
|
Column2: input.Search,
|
||||||
|
Column3: pgtype.Date{Time: parsedDate, Valid: parsedDateErr == nil},
|
||||||
|
Limit: int32(input.Limit),
|
||||||
|
Offset: int32(offset),
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.queries.GetProfissionaisDisponiveisLogistica(ctx, arg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
countArg := generated.CountProfissionaisDisponiveisLogisticaParams{
|
||||||
|
Regiao: pgtype.Text{String: regiao, Valid: true},
|
||||||
|
Column2: input.Search,
|
||||||
|
Column3: pgtype.Date{Time: parsedDate, Valid: parsedDateErr == nil},
|
||||||
|
}
|
||||||
|
total, err := s.queries.CountProfissionaisDisponiveisLogistica(ctx, countArg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if rows == nil {
|
||||||
|
rows = []generated.GetProfissionaisDisponiveisLogisticaRow{}
|
||||||
|
}
|
||||||
|
|
||||||
|
totalPages := int(total) / input.Limit
|
||||||
|
if int(total)%input.Limit > 0 {
|
||||||
|
totalPages++
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ListDisponiveisResult{
|
||||||
|
Data: rows,
|
||||||
|
Total: total,
|
||||||
|
Page: input.Page,
|
||||||
|
TotalPages: totalPages,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateConviteInput struct {
|
||||||
|
ProfissionalID string `json:"profissional_id" binding:"required"`
|
||||||
|
Data string `json:"data" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) CreateConviteDiario(ctx context.Context, input CreateConviteInput, regiao string) (*generated.ConvitesDiario, error) {
|
||||||
|
profUUID, err := uuid.Parse(input.ProfissionalID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
arg := generated.CreateConviteDiarioParams{
|
||||||
|
ProfissionalID: pgtype.UUID{Bytes: profUUID, Valid: true},
|
||||||
|
Status: pgtype.Text{String: "PENDENTE", Valid: true},
|
||||||
|
Regiao: pgtype.Text{String: regiao, Valid: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
t, err := time.Parse("2006-01-02", input.Data)
|
||||||
|
if err == nil {
|
||||||
|
arg.Data = pgtype.Date{Time: t, Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
convite, err := s.queries.CreateConviteDiario(ctx, arg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &convite, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List Invitations for a Professional
|
||||||
|
func (s *Service) ListConvitesDoProfissional(ctx context.Context, profID string) ([]generated.ConvitesDiario, error) {
|
||||||
|
pID, err := uuid.Parse(profID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.queries.GetConvitesByProfissional(ctx, pgtype.UUID{Bytes: pID, Valid: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Answer Invitation
|
||||||
|
type ResponderConviteInput struct {
|
||||||
|
Status string `json:"status" binding:"required"` // ACEITO, REJEITADO
|
||||||
|
MotivoRejeicao string `json:"motivo_rejeicao"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ResponderConvite(ctx context.Context, conviteID string, input ResponderConviteInput) (*generated.ConvitesDiario, error) {
|
||||||
|
cID, err := uuid.Parse(conviteID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
validStatus := input.Status == "ACEITO" || input.Status == "REJEITADO"
|
||||||
|
if !validStatus {
|
||||||
|
return nil, fmt.Errorf("status inválido") // fmt must be imported, Oh wait.. just use errors.New if fmt is not there. I will just rely on simple return. Wait I can use built-in error.
|
||||||
|
}
|
||||||
|
|
||||||
|
arg := generated.UpdateConviteStatusParams{
|
||||||
|
ID: pgtype.UUID{Bytes: cID, Valid: true},
|
||||||
|
Status: pgtype.Text{String: input.Status, Valid: true},
|
||||||
|
MotivoRejeicao: pgtype.Text{String: input.MotivoRejeicao, Valid: input.MotivoRejeicao != ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
convite, err := s.queries.UpdateConviteStatus(ctx, arg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &convite, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List Invitations by Date and Region (Logistics view)
|
||||||
|
func (s *Service) ListConvitesPorData(ctx context.Context, dataStr string, regiao string) ([]generated.ConvitesDiario, error) {
|
||||||
|
parsedDate, err := time.Parse("2006-01-02", dataStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("formato de data inválido, esperado YYYY-MM-DD")
|
||||||
|
}
|
||||||
|
|
||||||
|
datePg := pgtype.Date{
|
||||||
|
Time: parsedDate,
|
||||||
|
Valid: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
regPg := pgtype.Text{
|
||||||
|
String: regiao,
|
||||||
|
Valid: regiao != "",
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.queries.GetConvitesByDateAndRegion(ctx, generated.GetConvitesByDateAndRegionParams{
|
||||||
|
Data: datePg,
|
||||||
|
Regiao: regPg,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { RegistrationSuccess } from "./pages/RegistrationSuccess";
|
||||||
import { TeamPage } from "./pages/Team";
|
import { TeamPage } from "./pages/Team";
|
||||||
import EventDetails from "./pages/EventDetails";
|
import EventDetails from "./pages/EventDetails";
|
||||||
import Finance from "./pages/Finance";
|
import Finance from "./pages/Finance";
|
||||||
|
import { DailyLogistics } from "./pages/DailyLogistics";
|
||||||
|
|
||||||
import { SettingsPage } from "./pages/Settings";
|
import { SettingsPage } from "./pages/Settings";
|
||||||
import { CourseManagement } from "./pages/CourseManagement";
|
import { CourseManagement } from "./pages/CourseManagement";
|
||||||
|
|
@ -641,6 +642,16 @@ const AppContent: React.FC = () => {
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/logistica-diaria"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<PageWrapper>
|
||||||
|
<DailyLogistics />
|
||||||
|
</PageWrapper>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/agenda/:id"
|
path="/agenda/:id"
|
||||||
element={
|
element={
|
||||||
|
|
|
||||||
32
frontend/Dockerfile
Normal file
32
frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
# Build stage
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files and install dependencies
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy source code and build
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production stage (Nginx)
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# Copy built files to Nginx
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Default Nginx config for Single Page Apps (Vite)
|
||||||
|
RUN echo 'server { \
|
||||||
|
listen 80; \
|
||||||
|
location / { \
|
||||||
|
root /usr/share/nginx/html; \
|
||||||
|
index index.html; \
|
||||||
|
try_files $uri $uri/ /index.html; \
|
||||||
|
} \
|
||||||
|
}' > /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|
@ -48,7 +48,7 @@ export const EventCard: React.FC<EventCardProps> = ({ event, onClick }) => {
|
||||||
<div className="space-y-2 sm:space-y-3">
|
<div className="space-y-2 sm:space-y-3">
|
||||||
<div className="flex items-center text-gray-500 text-xs sm:text-sm">
|
<div className="flex items-center text-gray-500 text-xs sm:text-sm">
|
||||||
<Calendar size={14} className="sm:w-4 sm:h-4 mr-1.5 sm:mr-2 text-brand-gold flex-shrink-0" />
|
<Calendar size={14} className="sm:w-4 sm:h-4 mr-1.5 sm:mr-2 text-brand-gold flex-shrink-0" />
|
||||||
<span className="truncate">{new Date(event.date).toLocaleDateString()} às {event.time}</span>
|
<span className="truncate">{new Date(event.date).toLocaleDateString()} às {event.startTime || event.time}{event.endTime || event.horario_fim ? ` - ${event.endTime || event.horario_fim}` : ''}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Location with Tooltip */}
|
{/* Location with Tooltip */}
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,8 @@ import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import { MapboxResult } from "../services/mapboxService";
|
||||||
searchMapboxLocation,
|
import { searchGoogleLocation, reverseGeocodeGoogle } from "../services/googleMapsService";
|
||||||
MapboxResult,
|
|
||||||
reverseGeocode,
|
|
||||||
} from "../services/mapboxService";
|
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
import { useData } from "../contexts/DataContext";
|
import { useData } from "../contexts/DataContext";
|
||||||
import { UserRole } from "../types";
|
import { UserRole } from "../types";
|
||||||
|
|
@ -197,8 +194,8 @@ export const EventForm: React.FC<EventFormProps> = ({
|
||||||
setFormData(prev => ({
|
setFormData(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
...initialData,
|
...initialData,
|
||||||
startTime: initialData.time || (initialData as any).horario || "00:00",
|
startTime: initialData.time || initialData.startTime || (initialData as any).horario || "00:00",
|
||||||
endTime: (initialData as any).horario_termino || prev.endTime || "",
|
endTime: initialData.endTime || (initialData as any).horario_fim || prev.endTime || "",
|
||||||
locationName: mapLink.includes('http') ? "" : mapLink, // Avoid putting URL in name
|
locationName: mapLink.includes('http') ? "" : mapLink, // Avoid putting URL in name
|
||||||
fotId: mappedFotId,
|
fotId: mappedFotId,
|
||||||
name: mappedObservacoes, // Map Observacoes to Name field (displayed as "Observacoes do Evento")
|
name: mappedObservacoes, // Map Observacoes to Name field (displayed as "Observacoes do Evento")
|
||||||
|
|
@ -293,7 +290,7 @@ export const EventForm: React.FC<EventFormProps> = ({
|
||||||
const timer = setTimeout(async () => {
|
const timer = setTimeout(async () => {
|
||||||
if (addressQuery.length > 3) {
|
if (addressQuery.length > 3) {
|
||||||
setIsSearching(true);
|
setIsSearching(true);
|
||||||
const results = await searchMapboxLocation(addressQuery);
|
const results = await searchGoogleLocation(addressQuery);
|
||||||
setAddressResults(results);
|
setAddressResults(results);
|
||||||
setIsSearching(false);
|
setIsSearching(false);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -306,6 +303,7 @@ export const EventForm: React.FC<EventFormProps> = ({
|
||||||
const handleAddressSelect = (addr: MapboxResult) => {
|
const handleAddressSelect = (addr: MapboxResult) => {
|
||||||
setFormData((prev: any) => ({
|
setFormData((prev: any) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
locationName: addr.placeName || prev.locationName,
|
||||||
address: {
|
address: {
|
||||||
street: addr.street,
|
street: addr.street,
|
||||||
number: addr.number,
|
number: addr.number,
|
||||||
|
|
@ -322,11 +320,12 @@ export const EventForm: React.FC<EventFormProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMapLocationChange = async (lat: number, lng: number) => {
|
const handleMapLocationChange = async (lat: number, lng: number) => {
|
||||||
const addressData = await reverseGeocode(lat, lng);
|
const addressData = await reverseGeocodeGoogle(lat, lng);
|
||||||
|
|
||||||
if (addressData) {
|
if (addressData) {
|
||||||
setFormData((prev: any) => ({
|
setFormData((prev: any) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
locationName: addressData.placeName || prev.locationName,
|
||||||
address: {
|
address: {
|
||||||
street: addressData.street,
|
street: addressData.street,
|
||||||
number: addressData.number,
|
number: addressData.number,
|
||||||
|
|
@ -358,11 +357,12 @@ export const EventForm: React.FC<EventFormProps> = ({
|
||||||
|
|
||||||
setIsGeocoding(true);
|
setIsGeocoding(true);
|
||||||
try {
|
try {
|
||||||
const results = await searchMapboxLocation(query);
|
const results = await searchGoogleLocation(query);
|
||||||
if (results.length > 0) {
|
if (results.length > 0) {
|
||||||
const firstResult = results[0];
|
const firstResult = results[0];
|
||||||
setFormData((prev: any) => ({
|
setFormData((prev: any) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
locationName: firstResult.placeName || prev.locationName,
|
||||||
address: {
|
address: {
|
||||||
...prev.address,
|
...prev.address,
|
||||||
lat: firstResult.lat,
|
lat: firstResult.lat,
|
||||||
|
|
@ -439,6 +439,7 @@ export const EventForm: React.FC<EventFormProps> = ({
|
||||||
tipo_evento_id: finalTypeId,
|
tipo_evento_id: finalTypeId,
|
||||||
data_evento: new Date(formData.date).toISOString(),
|
data_evento: new Date(formData.date).toISOString(),
|
||||||
horario: formData.startTime || "",
|
horario: formData.startTime || "",
|
||||||
|
horario_fim: formData.endTime || "",
|
||||||
observacoes_evento: formData.name || formData.briefing || "",
|
observacoes_evento: formData.name || formData.briefing || "",
|
||||||
local_evento: formData.locationName || "",
|
local_evento: formData.locationName || "",
|
||||||
endereco: `${formData.address.street}, ${formData.address.number} - ${formData.address.city}/${formData.address.state}`,
|
endereco: `${formData.address.street}, ${formData.address.number} - ${formData.address.city}/${formData.address.state}`,
|
||||||
|
|
@ -929,7 +930,7 @@ export const EventForm: React.FC<EventFormProps> = ({
|
||||||
|
|
||||||
<div className="bg-gray-50 p-4 rounded-lg border border-gray-200">
|
<div className="bg-gray-50 p-4 rounded-lg border border-gray-200">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2 tracking-wide uppercase text-xs">
|
<label className="block text-sm font-medium text-gray-700 mb-2 tracking-wide uppercase text-xs">
|
||||||
Busca de Endereço (Powered by Mapbox)
|
BUSCA DE ENDEREÇO (POWERED BY GOOGLE)
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ interface EventSchedulerProps {
|
||||||
allowedProfessionals?: { professional_id?: string; professionalId?: string; status?: string }[] | string[]; // IDs or Objects
|
allowedProfessionals?: { professional_id?: string; professionalId?: string; status?: string }[] | string[]; // IDs or Objects
|
||||||
onUpdateStats?: (stats: { studios: number }) => void;
|
onUpdateStats?: (stats: { studios: number }) => void;
|
||||||
defaultTime?: string;
|
defaultTime?: string;
|
||||||
|
defaultEndTime?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeSlots = [
|
const timeSlots = [
|
||||||
|
|
@ -19,7 +20,7 @@ const timeSlots = [
|
||||||
"19:00", "20:00", "21:00", "22:00", "23:00", "00:00"
|
"19:00", "20:00", "21:00", "22:00", "23:00", "00:00"
|
||||||
];
|
];
|
||||||
|
|
||||||
const EventScheduler: React.FC<EventSchedulerProps> = ({ agendaId, dataEvento, allowedProfessionals, onUpdateStats, defaultTime }) => {
|
const EventScheduler: React.FC<EventSchedulerProps> = ({ agendaId, dataEvento, allowedProfessionals, onUpdateStats, defaultTime, defaultEndTime }) => {
|
||||||
const { token, user } = useAuth();
|
const { token, user } = useAuth();
|
||||||
const { professionals, events, functions } = useData();
|
const { professionals, events, functions } = useData();
|
||||||
const [escalas, setEscalas] = useState<any[]>([]);
|
const [escalas, setEscalas] = useState<any[]>([]);
|
||||||
|
|
@ -29,7 +30,7 @@ const EventScheduler: React.FC<EventSchedulerProps> = ({ agendaId, dataEvento, a
|
||||||
// New entry state
|
// New entry state
|
||||||
const [selectedProf, setSelectedProf] = useState("");
|
const [selectedProf, setSelectedProf] = useState("");
|
||||||
const [startTime, setStartTime] = useState(defaultTime || "08:00");
|
const [startTime, setStartTime] = useState(defaultTime || "08:00");
|
||||||
const [endTime, setEndTime] = useState("12:00"); // Could calculated based on start, but keep simple
|
const [endTime, setEndTime] = useState(defaultEndTime || "12:00"); // Could calculated based on start, but keep simple
|
||||||
const [role, setRole] = useState("");
|
const [role, setRole] = useState("");
|
||||||
|
|
||||||
const isEditable = user?.role === UserRole.SUPERADMIN || user?.role === UserRole.BUSINESS_OWNER;
|
const isEditable = user?.role === UserRole.SUPERADMIN || user?.role === UserRole.BUSINESS_OWNER;
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,7 @@ export const Navbar: React.FC<NavbarProps> = ({ onNavigate, currentPage }) => {
|
||||||
return [
|
return [
|
||||||
{ name: "Agenda", path: "painel" },
|
{ name: "Agenda", path: "painel" },
|
||||||
{ name: "Equipe", path: "equipe" },
|
{ name: "Equipe", path: "equipe" },
|
||||||
|
{ name: "Logística", path: "logistica-diaria" },
|
||||||
{ name: "Cadastro de FOT", path: "cursos" },
|
{ name: "Cadastro de FOT", path: "cursos" },
|
||||||
{ name: "Aprovação de Cadastros", path: "aprovacao-cadastros" },
|
{ name: "Aprovação de Cadastros", path: "aprovacao-cadastros" },
|
||||||
{ name: "Códigos de Acesso", path: "codigos-acesso" },
|
{ name: "Códigos de Acesso", path: "codigos-acesso" },
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { createContext, useContext, useState, ReactNode, useEffect } from "react";
|
import React, { createContext, useContext, useState, ReactNode, useEffect } from "react";
|
||||||
import { useAuth } from "./AuthContext";
|
import { useAuth } from "./AuthContext";
|
||||||
import { getPendingUsers, approveUser as apiApproveUser, getProfessionals, assignProfessional as apiAssignProfessional, removeProfessional as apiRemoveProfessional, updateEventStatus as apiUpdateStatus, updateAssignmentStatus as apiUpdateAssignmentStatus, updateAgenda as apiUpdateAgenda } from "../services/apiService";
|
import { getPendingUsers, approveUser as apiApproveUser, getProfessionals, assignProfessional as apiAssignProfessional, removeProfessional as apiRemoveProfessional, updateEventStatus as apiUpdateStatus, updateAssignmentStatus as apiUpdateAssignmentStatus, updateAgenda as apiUpdateAgenda } from "../services/apiService";
|
||||||
|
import { useRegion } from "./RegionContext";
|
||||||
import {
|
import {
|
||||||
EventData,
|
EventData,
|
||||||
EventStatus,
|
EventStatus,
|
||||||
|
|
@ -621,6 +622,7 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({
|
||||||
children,
|
children,
|
||||||
}) => {
|
}) => {
|
||||||
const { token, user } = useAuth(); // Consume Auth Context
|
const { token, user } = useAuth(); // Consume Auth Context
|
||||||
|
const { currentRegion, isRegionReady } = useRegion();
|
||||||
const [events, setEvents] = useState<EventData[]>([]);
|
const [events, setEvents] = useState<EventData[]>([]);
|
||||||
const [institutions, setInstitutions] =
|
const [institutions, setInstitutions] =
|
||||||
useState<Institution[]>(INITIAL_INSTITUTIONS);
|
useState<Institution[]>(INITIAL_INSTITUTIONS);
|
||||||
|
|
@ -639,7 +641,7 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({
|
||||||
// Use token from context or fallback to localStorage if context not ready (though context is preferred sources of truth)
|
// Use token from context or fallback to localStorage if context not ready (though context is preferred sources of truth)
|
||||||
const visibleToken = token || localStorage.getItem("token");
|
const visibleToken = token || localStorage.getItem("token");
|
||||||
|
|
||||||
if (visibleToken) {
|
if (visibleToken && isRegionReady) {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
// Import dynamic to avoid circular dependency if any, or just use imported service
|
// Import dynamic to avoid circular dependency if any, or just use imported service
|
||||||
|
|
@ -678,6 +680,8 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({
|
||||||
name: e.observacoes_evento || e.tipo_evento_nome || "Evento sem nome", // Fallback mapping
|
name: e.observacoes_evento || e.tipo_evento_nome || "Evento sem nome", // Fallback mapping
|
||||||
date: e.data_evento ? e.data_evento.split('T')[0] : "",
|
date: e.data_evento ? e.data_evento.split('T')[0] : "",
|
||||||
time: e.horario || "00:00",
|
time: e.horario || "00:00",
|
||||||
|
startTime: e.horario || "00:00",
|
||||||
|
endTime: e.horario_fim || "",
|
||||||
type: (e.tipo_evento_nome || "Outro") as EventType, // Map string to enum if possible, or keep string
|
type: (e.tipo_evento_nome || "Outro") as EventType, // Map string to enum if possible, or keep string
|
||||||
status: mapStatus(e.status), // Map from backend status with fallback
|
status: mapStatus(e.status), // Map from backend status with fallback
|
||||||
address: {
|
address: {
|
||||||
|
|
@ -747,7 +751,7 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchEvents();
|
fetchEvents();
|
||||||
}, [token, refreshTrigger]); // React to token change and manual refresh
|
}, [token, refreshTrigger, currentRegion, isRegionReady]); // React to context changes
|
||||||
|
|
||||||
const refreshEvents = async () => {
|
const refreshEvents = async () => {
|
||||||
setRefreshTrigger(prev => prev + 1);
|
setRefreshTrigger(prev => prev + 1);
|
||||||
|
|
@ -795,7 +799,7 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchProfs = async () => {
|
const fetchProfs = async () => {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
if (token) {
|
if (token && isRegionReady) {
|
||||||
try {
|
try {
|
||||||
const result = await getProfessionals(token);
|
const result = await getProfessionals(token);
|
||||||
if (result.data) {
|
if (result.data) {
|
||||||
|
|
@ -850,7 +854,7 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchProfs();
|
fetchProfs();
|
||||||
}, [token]);
|
}, [token, currentRegion, isRegionReady]);
|
||||||
|
|
||||||
const addEvent = async (event: any) => {
|
const addEvent = async (event: any) => {
|
||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem("token");
|
||||||
|
|
@ -1180,6 +1184,8 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({
|
||||||
...evt,
|
...evt,
|
||||||
date: data.data_evento ? data.data_evento.split("T")[0] : evt.date,
|
date: data.data_evento ? data.data_evento.split("T")[0] : evt.date,
|
||||||
time: data.horario || evt.time,
|
time: data.horario || evt.time,
|
||||||
|
startTime: data.horario || evt.startTime,
|
||||||
|
endTime: data.horario_fim || evt.endTime,
|
||||||
name: data.observacoes_evento || evt.name,
|
name: data.observacoes_evento || evt.name,
|
||||||
briefing: data.observacoes_evento || evt.briefing,
|
briefing: data.observacoes_evento || evt.briefing,
|
||||||
fotId: data.fot_id || evt.fotId,
|
fotId: data.fot_id || evt.fotId,
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,14 @@ interface RegionContextType {
|
||||||
currentRegion: string;
|
currentRegion: string;
|
||||||
setRegion: (region: string) => void;
|
setRegion: (region: string) => void;
|
||||||
availableRegions: string[];
|
availableRegions: string[];
|
||||||
|
isRegionReady: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RegionContext = createContext<RegionContextType>({
|
const RegionContext = createContext<RegionContextType>({
|
||||||
currentRegion: "SP",
|
currentRegion: "SP",
|
||||||
setRegion: () => {},
|
setRegion: () => {},
|
||||||
availableRegions: [],
|
availableRegions: [],
|
||||||
|
isRegionReady: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const RegionProvider: React.FC<{ children: React.ReactNode }> = ({
|
export const RegionProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||||
|
|
@ -32,9 +34,18 @@ export const RegionProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||||
// Let's assume public = SP only or no switcher.
|
// Let's assume public = SP only or no switcher.
|
||||||
// BUT: If user is logged out, they shouldn't see switcher anyway.
|
// BUT: If user is logged out, they shouldn't see switcher anyway.
|
||||||
const [availableRegions, setAvailableRegions] = useState<string[]>(["SP"]);
|
const [availableRegions, setAvailableRegions] = useState<string[]>(["SP"]);
|
||||||
|
const [isRegionReady, setIsRegionReady] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log("RegionContext Debug:", { user, allowedRegions: user?.allowedRegions });
|
console.log("RegionContext Debug:", { user, allowedRegions: user?.allowedRegions });
|
||||||
|
// If not logged in or user still fetching, wait (but if public page, we could mark ready. For now, mark ready if no token or after user loads)
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
if (token && !user) {
|
||||||
|
// Wait for user to load to evaluate regions
|
||||||
|
setIsRegionReady(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (user && user.allowedRegions && user.allowedRegions.length > 0) {
|
if (user && user.allowedRegions && user.allowedRegions.length > 0) {
|
||||||
setAvailableRegions(user.allowedRegions);
|
setAvailableRegions(user.allowedRegions);
|
||||||
|
|
||||||
|
|
@ -49,7 +60,9 @@ export const RegionProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||||
// Fallback or Public
|
// Fallback or Public
|
||||||
setAvailableRegions(["SP"]);
|
setAvailableRegions(["SP"]);
|
||||||
}
|
}
|
||||||
}, [user, user?.allowedRegions, currentRegion]);
|
|
||||||
|
setIsRegionReady(true);
|
||||||
|
}, [user, currentRegion]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem(REGION_KEY, currentRegion);
|
localStorage.setItem(REGION_KEY, currentRegion);
|
||||||
|
|
@ -67,7 +80,7 @@ export const RegionProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RegionContext.Provider
|
<RegionContext.Provider
|
||||||
value={{ currentRegion, setRegion, availableRegions }}
|
value={{ currentRegion, setRegion, availableRegions, isRegionReady }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</RegionContext.Provider>
|
</RegionContext.Provider>
|
||||||
|
|
|
||||||
1021
frontend/package-lock.json
generated
1021
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -11,6 +11,8 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google/genai": "^1.30.0",
|
"@google/genai": "^1.30.0",
|
||||||
"@types/mapbox-gl": "^3.4.1",
|
"@types/mapbox-gl": "^3.4.1",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
|
"file-saver": "^2.0.5",
|
||||||
"lucide-react": "^0.554.0",
|
"lucide-react": "^0.554.0",
|
||||||
"mapbox-gl": "^3.16.0",
|
"mapbox-gl": "^3.16.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
|
|
|
||||||
574
frontend/pages/DailyLogistics.tsx
Normal file
574
frontend/pages/DailyLogistics.tsx
Normal file
|
|
@ -0,0 +1,574 @@
|
||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { useData } from '../contexts/DataContext';
|
||||||
|
import { EventData, EventStatus, Professional } from '../types';
|
||||||
|
import { RefreshCw, Users, CheckCircle, AlertTriangle, Calendar as CalendarIcon, MapPin, Clock, UserPlus, ChevronLeft, ChevronRight, ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { getAvailableProfessionalsLogistics, createDailyInvitation, getDailyInvitationsByDate } from '../services/apiService';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
import { ProfessionalDetailsModal } from '../components/ProfessionalDetailsModal';
|
||||||
|
import { Car } from 'lucide-react'; // Added Car icon
|
||||||
|
|
||||||
|
export const DailyLogistics: React.FC = () => {
|
||||||
|
const { events, professionals, isLoading, refreshEvents } = useData();
|
||||||
|
const [selectedDate, setSelectedDate] = useState<string>(new Date().toISOString().split('T')[0]);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [dailyInvitations, setDailyInvitations] = useState<any[]>([]);
|
||||||
|
const [isInvitationsExpanded, setIsInvitationsExpanded] = useState(true);
|
||||||
|
const [viewingProfessional, setViewingProfessional] = useState<Professional | null>(null);
|
||||||
|
const [isProfModalOpen, setIsProfModalOpen] = useState(false);
|
||||||
|
|
||||||
|
// Utility para cores de cidade baseado na planilha do cliente + cores dinâmicas
|
||||||
|
const getCityColor = (city: string | undefined) => {
|
||||||
|
if (!city) return 'bg-gray-100 text-gray-700 border-gray-200'; // Default
|
||||||
|
const c = city.toLowerCase().trim();
|
||||||
|
if (c.includes('campinas')) return 'bg-pink-100 text-pink-700 border-pink-200';
|
||||||
|
if (c.includes('piracicaba')) return 'bg-cyan-100 text-cyan-700 border-cyan-200';
|
||||||
|
if (c.includes('paulo') || c.includes('sp')) return 'bg-slate-200 text-slate-700 border-slate-300';
|
||||||
|
if (c.includes('americana') || c.includes('sbo') || c.includes('barbara') || c.includes('bárbara') || c.includes('santa barbara')) return 'bg-green-100 text-green-700 border-green-200';
|
||||||
|
if (c.includes('indaiatuba')) return 'bg-yellow-100 text-yellow-700 border-yellow-200';
|
||||||
|
|
||||||
|
// Dynamic color for others
|
||||||
|
const palette = [
|
||||||
|
'bg-purple-100 text-purple-700 border-purple-200',
|
||||||
|
'bg-indigo-100 text-indigo-700 border-indigo-200',
|
||||||
|
'bg-teal-100 text-teal-700 border-teal-200',
|
||||||
|
'bg-orange-100 text-orange-700 border-orange-200',
|
||||||
|
'bg-rose-100 text-rose-700 border-rose-200',
|
||||||
|
'bg-violet-100 text-violet-700 border-violet-200',
|
||||||
|
'bg-emerald-100 text-emerald-700 border-emerald-200',
|
||||||
|
'bg-fuchsia-100 text-fuchsia-700 border-fuchsia-200'
|
||||||
|
];
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < c.length; i++) {
|
||||||
|
hash = c.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
}
|
||||||
|
return palette[Math.abs(hash) % palette.length];
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setSelectedDate(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
refreshEvents();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter events by the selected date
|
||||||
|
const eventsInDay = useMemo(() => {
|
||||||
|
return events.filter(e => {
|
||||||
|
if (!e.date) return false;
|
||||||
|
// e.date might be YYYY-MM-DD or full ISO
|
||||||
|
const eventDateStr = e.date.split('T')[0];
|
||||||
|
return eventDateStr === selectedDate;
|
||||||
|
});
|
||||||
|
}, [events, selectedDate]);
|
||||||
|
|
||||||
|
// Aggregate metrics
|
||||||
|
const metrics = useMemo(() => {
|
||||||
|
let totalNeeded = 0;
|
||||||
|
let totalAccepted = 0;
|
||||||
|
let totalPending = 0;
|
||||||
|
|
||||||
|
// Detailed Role missing counts
|
||||||
|
let fotMissing = 0;
|
||||||
|
let recMissing = 0;
|
||||||
|
let cinMissing = 0;
|
||||||
|
|
||||||
|
// Track professionals already counted in events for this day
|
||||||
|
const eventAcceptedProfIds = new Set<string>();
|
||||||
|
const eventPendingProfIds = new Set<string>();
|
||||||
|
|
||||||
|
eventsInDay.forEach(event => {
|
||||||
|
// Required professionals
|
||||||
|
const fotReq = Number(event.qtdFotografos || 0);
|
||||||
|
const recReq = Number(event.qtdRecepcionistas || 0);
|
||||||
|
const cinReq = Number(event.qtdCinegrafistas || 0);
|
||||||
|
|
||||||
|
const eventTotalNeeded = fotReq + recReq + cinReq;
|
||||||
|
totalNeeded += eventTotalNeeded;
|
||||||
|
|
||||||
|
let fotAc = 0, recAc = 0, cinAc = 0;
|
||||||
|
let eTotalAc = 0, eTotalPd = 0;
|
||||||
|
|
||||||
|
// Count assignments
|
||||||
|
if (event.assignments) {
|
||||||
|
event.assignments.forEach(assignment => {
|
||||||
|
const status = assignment.status ? String(assignment.status).toUpperCase() : "AGUARDANDO";
|
||||||
|
const prof = professionals.find(p => p.id === assignment.professionalId);
|
||||||
|
const roleName = prof?.role?.toLowerCase() || "";
|
||||||
|
|
||||||
|
if (status === "ACEITO" || status === "CONFIRMADO") {
|
||||||
|
eTotalAc++;
|
||||||
|
eventAcceptedProfIds.add(assignment.professionalId);
|
||||||
|
if (roleName.includes('fot')) fotAc++;
|
||||||
|
else if (roleName.includes('rece')) recAc++;
|
||||||
|
else if (roleName.includes('cin')) cinAc++;
|
||||||
|
else if (assignment.funcaoId === 'fotografo_id') fotAc++;
|
||||||
|
} else if (status === "AGUARDANDO" || status === "PENDENTE") {
|
||||||
|
eTotalPd++;
|
||||||
|
eventPendingProfIds.add(assignment.professionalId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
totalAccepted += eTotalAc;
|
||||||
|
totalPending += eTotalPd;
|
||||||
|
|
||||||
|
// Calculate missing per role for this event
|
||||||
|
fotMissing += Math.max(0, fotReq - fotAc);
|
||||||
|
recMissing += Math.max(0, recReq - recAc);
|
||||||
|
cinMissing += Math.max(0, cinReq - cinAc);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add Daily Invitations to the generic counters only if not already allocated in an event
|
||||||
|
const uniqueDailyPending = dailyInvitations.filter(i => i.status === 'PENDENTE' && !eventPendingProfIds.has(i.profissional_id) && !eventAcceptedProfIds.has(i.profissional_id)).length;
|
||||||
|
const uniqueDailyAccepted = dailyInvitations.filter(i => i.status === 'ACEITO' && !eventAcceptedProfIds.has(i.profissional_id)).length;
|
||||||
|
|
||||||
|
totalPending += uniqueDailyPending;
|
||||||
|
totalAccepted += uniqueDailyAccepted;
|
||||||
|
|
||||||
|
const totalMissing = Math.max(0, totalNeeded - totalAccepted);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalNeeded, totalAccepted, totalPending, totalMissing,
|
||||||
|
fotMissing, recMissing, cinMissing
|
||||||
|
};
|
||||||
|
}, [eventsInDay, professionals, dailyInvitations]);
|
||||||
|
|
||||||
|
const [paginatedProfessionals, setPaginatedProfessionals] = useState<any[]>([]);
|
||||||
|
const [totalPros, setTotalPros] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [isProsLoading, setIsProsLoading] = useState(false);
|
||||||
|
|
||||||
|
// Helper: Fetch available professionals from API
|
||||||
|
const fetchPros = async () => {
|
||||||
|
setIsProsLoading(true);
|
||||||
|
try {
|
||||||
|
const resp = await getAvailableProfessionalsLogistics(selectedDate, page, 50, searchTerm);
|
||||||
|
setPaginatedProfessionals(resp.data || []);
|
||||||
|
setTotalPros(resp.total || 0);
|
||||||
|
setTotalPages(resp.total_pages || 1);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch professionals", err);
|
||||||
|
toast.error("Erro ao carregar profissionais");
|
||||||
|
} finally {
|
||||||
|
setIsProsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchDailyInvitations = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getDailyInvitationsByDate(selectedDate);
|
||||||
|
setDailyInvitations(res || []);
|
||||||
|
} catch(err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPros();
|
||||||
|
fetchDailyInvitations();
|
||||||
|
}, [selectedDate, page, searchTerm]);
|
||||||
|
|
||||||
|
// Select Event Context Handlers -> Now it's Daily Allocation!
|
||||||
|
const allocDaily = async (profId: string) => {
|
||||||
|
try {
|
||||||
|
await createDailyInvitation(profId, selectedDate);
|
||||||
|
toast.success("Convite de diária enviado!");
|
||||||
|
fetchPros(); // Refresh list to remove from available
|
||||||
|
fetchDailyInvitations(); // refresh the pending invitations section
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || "Erro ao alocar diária");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render individual event card
|
||||||
|
const renderEventCard = (event: EventData) => {
|
||||||
|
const fotReq = Number(event.qtdFotografos || 0);
|
||||||
|
const recReq = Number(event.qtdRecepcionistas || 0);
|
||||||
|
const cinReq = Number(event.qtdCinegrafistas || 0);
|
||||||
|
const needed = fotReq + recReq + cinReq;
|
||||||
|
|
||||||
|
// Group accepted/pending counts by role directly from event's assignments
|
||||||
|
// We assume assignments map back to the professional's functions or are inherently mapped
|
||||||
|
let accepted = 0;
|
||||||
|
let pending = 0;
|
||||||
|
|
||||||
|
let fotAc = 0, recAc = 0, cinAc = 0;
|
||||||
|
|
||||||
|
event.assignments?.forEach(a => {
|
||||||
|
const status = a.status ? String(a.status).toUpperCase() : "AGUARDANDO";
|
||||||
|
const prof = professionals.find(p => p.id === a.professionalId);
|
||||||
|
const roleName = prof?.role?.toLowerCase() || "";
|
||||||
|
|
||||||
|
if (status === "ACEITO" || status === "CONFIRMADO") {
|
||||||
|
accepted++;
|
||||||
|
if (roleName.includes('fot')) fotAc++;
|
||||||
|
else if (roleName.includes('rece')) recAc++;
|
||||||
|
else if (roleName.includes('cin')) cinAc++;
|
||||||
|
else if (a.funcaoId === 'fotografo_id') fotAc++; // Fallback maps
|
||||||
|
}
|
||||||
|
else if (status === "AGUARDANDO" || status === "PENDENTE") pending++;
|
||||||
|
});
|
||||||
|
const missing = Math.max(0, needed - accepted);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={event.id}
|
||||||
|
onClick={() => navigate(`/painel?eventId=${event.id}`)}
|
||||||
|
className="bg-white rounded-xl shadow-sm border border-gray-100 p-5 hover:shadow-md hover:border-brand-purple/50 transition-all cursor-pointer group"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-bold text-gray-900 group-hover:text-brand-purple transition-colors line-clamp-1 flex items-center gap-2">
|
||||||
|
{event.name}
|
||||||
|
<ChevronRight size={16} className="text-transparent group-hover:text-brand-purple transition-colors" />
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center text-xs text-gray-500 mt-2 gap-4">
|
||||||
|
<span className="flex items-center gap-1"><Clock size={14}/> {event.startTime || event.time}{event.endTime || event.horario_fim ? ` às ${event.endTime || event.horario_fim}` : ''}</span>
|
||||||
|
<span className="flex items-center gap-1 truncate max-w-[150px]"><MapPin size={14}/> {event.address?.city || "Cidade N/A"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={`px-2 py-1 rounded-md text-xs font-semibold shrink-0 ml-2 ${missing > 0 ? 'bg-red-50 text-red-600' : 'bg-green-50 text-green-600'}`}>
|
||||||
|
{missing > 0 ? `Faltam ${missing}` : 'Completo'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Roles Breakdown */}
|
||||||
|
<div className="bg-gray-50 rounded-lg p-3 text-xs flex flex-col gap-2 border border-gray-100 mb-4">
|
||||||
|
<div className="flex justify-between items-center text-gray-600 font-medium pb-1 border-b border-gray-200">
|
||||||
|
<span>Função</span>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<span className="w-12 text-center" title="Aceitos / Necessários">A / N</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fotReq > 0 && (
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-gray-700">Fotógrafo</span>
|
||||||
|
<div className="flex gap-4 text-center">
|
||||||
|
<span className={`w-12 font-semibold ${fotAc < fotReq ? 'text-yellow-600' : 'text-green-600'}`}>
|
||||||
|
{fotAc} / {fotReq}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{recReq > 0 && (
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-gray-700">Recepcionista</span>
|
||||||
|
<div className="flex gap-4 text-center">
|
||||||
|
<span className={`w-12 font-semibold ${recAc < recReq ? 'text-yellow-600' : 'text-green-600'}`}>
|
||||||
|
{recAc} / {recReq}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{cinReq > 0 && (
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-gray-700">Cinegrafista</span>
|
||||||
|
<div className="flex gap-4 text-center">
|
||||||
|
<span className={`w-12 font-semibold ${cinAc < cinReq ? 'text-yellow-600' : 'text-green-600'}`}>
|
||||||
|
{cinAc} / {cinReq}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{needed === 0 && (
|
||||||
|
<div className="text-gray-400 italic text-center py-1">Nenhuma vaga registrada</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{Array.isArray(event.assignments) && event.assignments.length > 0 && (
|
||||||
|
<div className="mt-2 text-xs">
|
||||||
|
<div className="flex justify-between text-gray-500 mb-1">
|
||||||
|
<span>Total de Convites:</span>
|
||||||
|
<span className="font-semibold">{event.assignments.length}</span>
|
||||||
|
</div>
|
||||||
|
{pending > 0 && (
|
||||||
|
<div className="flex justify-between text-yellow-600">
|
||||||
|
<span>Aguardando resposta:</span>
|
||||||
|
<span className="font-semibold">{pending}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 pt-24 animate-fade-in">
|
||||||
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Logística Diária</h1>
|
||||||
|
<p className="text-gray-500">Acompanhamento e escala de equipe por data da agenda</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<CalendarIcon size={18} className="text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={selectedDate}
|
||||||
|
onChange={handleDateChange}
|
||||||
|
className="pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-purple"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="p-2 bg-white border border-gray-300 rounded-lg text-gray-600 hover:bg-gray-50 hover:text-brand-purple transition-all"
|
||||||
|
title="Atualizar dados"
|
||||||
|
>
|
||||||
|
<RefreshCw size={20} className={isLoading ? "animate-spin" : ""} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dash/Metrics Panel */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||||
|
<div className="bg-white rounded-xl p-6 shadow-sm border border-gray-100 flex items-center gap-4">
|
||||||
|
<div className="p-3 bg-blue-50 text-blue-600 rounded-lg"><Users size={24}/></div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500 font-medium uppercase">Necessários</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{metrics.totalNeeded}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl p-6 shadow-sm border border-gray-100 flex items-center gap-4">
|
||||||
|
<div className="p-3 bg-green-50 text-green-600 rounded-lg"><CheckCircle size={24}/></div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500 font-medium uppercase">Aceitos</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{metrics.totalAccepted}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl p-6 shadow-sm border border-gray-100 flex items-center gap-4">
|
||||||
|
<div className="p-3 bg-yellow-50 text-yellow-600 rounded-lg"><RefreshCw size={24}/></div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500 font-medium uppercase">Pendentes</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{metrics.totalPending}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl p-6 shadow-sm border border-red-200 flex flex-col justify-center">
|
||||||
|
<div className="flex items-center gap-4 mb-2">
|
||||||
|
<div className="p-3 bg-red-50 text-red-600 rounded-lg"><AlertTriangle size={24}/></div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500 font-medium uppercase">Faltam (Geral)</p>
|
||||||
|
<p className="text-2xl font-bold text-red-600">{metrics.totalMissing}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Detailed Missing Roles */}
|
||||||
|
{metrics.totalMissing > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 text-xs border-t border-red-100 pt-3 mt-1">
|
||||||
|
{metrics.fotMissing > 0 && (
|
||||||
|
<span className="bg-red-50 text-red-600 px-2 py-1 rounded-md font-medium">Fotógrafos: {metrics.fotMissing}</span>
|
||||||
|
)}
|
||||||
|
{metrics.recMissing > 0 && (
|
||||||
|
<span className="bg-red-50 text-red-600 px-2 py-1 rounded-md font-medium">Recepcionistas: {metrics.recMissing}</span>
|
||||||
|
)}
|
||||||
|
{metrics.cinMissing > 0 && (
|
||||||
|
<span className="bg-red-50 text-red-600 px-2 py-1 rounded-md font-medium">Cinegrafistas: {metrics.cinMissing}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Daily Invitations Section */}
|
||||||
|
{dailyInvitations.length > 0 && (
|
||||||
|
<div className="mb-6 bg-white rounded-xl shadow-sm border border-gray-100 p-5">
|
||||||
|
<div
|
||||||
|
className="flex justify-between items-center cursor-pointer mb-1 hover:bg-gray-50 p-2 -mx-2 rounded-lg transition-colors"
|
||||||
|
onClick={() => setIsInvitationsExpanded(!isInvitationsExpanded)}
|
||||||
|
>
|
||||||
|
<h3 className="font-bold text-gray-900 flex items-center gap-2">
|
||||||
|
<Users size={18} className="text-brand-purple" /> Convites Enviados ({dailyInvitations.length})
|
||||||
|
</h3>
|
||||||
|
<div className="text-gray-400 hover:text-brand-purple transition-colors">
|
||||||
|
{isInvitationsExpanded ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isInvitationsExpanded && (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 mt-3">
|
||||||
|
{dailyInvitations.map((inv: any) => {
|
||||||
|
const prof = professionals.find(p => p.id === inv.profissional_id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={inv.id}
|
||||||
|
className="flex flex-col sm:flex-row sm:items-center justify-between bg-gray-50 border border-gray-100 rounded-lg p-3 gap-2 cursor-pointer hover:border-brand-purple/40 hover:shadow-sm transition-all"
|
||||||
|
onClick={() => {
|
||||||
|
if (prof) {
|
||||||
|
setViewingProfessional(prof);
|
||||||
|
setIsProfModalOpen(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<img src={prof?.avatar_url || `https://ui-avatars.com/api/?name=${prof?.nome || 'P'}&background=random`} alt={prof?.nome} className="w-8 h-8 rounded-full" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="font-semibold text-sm text-gray-900 leading-tight truncate max-w-[120px]" title={prof?.nome || 'Desconhecido'}>{prof?.nome || 'Desconhecido'}</p>
|
||||||
|
{prof?.carro_disponivel && (
|
||||||
|
<Car size={13} className="text-gray-400 shrink-0" title="Possui Carro" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-brand-gold uppercase tracking-wider truncate">{prof?.role || 'Profissional'}</p>
|
||||||
|
{prof?.cidade && (
|
||||||
|
<span className={`inline-block mt-0.5 px-1.5 py-0.5 text-[9px] uppercase font-bold tracking-widest rounded border ${getCityColor(prof.cidade)}`}>
|
||||||
|
{prof.cidade}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={`px-2 py-1 text-[10px] font-bold rounded uppercase tracking-wider self-start sm:self-auto ${
|
||||||
|
inv.status === 'ACEITO' ? 'bg-green-100 text-green-700' :
|
||||||
|
inv.status === 'REJEITADO' ? 'bg-red-100 text-red-700' :
|
||||||
|
'bg-yellow-100 text-yellow-700'
|
||||||
|
}`}>
|
||||||
|
{inv.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Layout with Events (Left) and Available Professionals Sidebar (Right) */}
|
||||||
|
<div className="flex flex-col lg:flex-row gap-8">
|
||||||
|
{/* Left Column: Events */}
|
||||||
|
<div className="flex-1">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-20">
|
||||||
|
<RefreshCw size={32} className="text-brand-purple animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : eventsInDay.length === 0 ? (
|
||||||
|
<div className="text-center py-20 bg-white rounded-2xl border border-dashed border-gray-300">
|
||||||
|
<CalendarIcon size={48} className="mx-auto text-gray-300 mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Nenhum evento nesta data</h3>
|
||||||
|
<p className="text-gray-500 mt-1">Selecione outro dia no calendário acima.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{eventsInDay.map(renderEventCard)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column: Sticky Sidebar for Professionals */}
|
||||||
|
<div className="w-full lg:w-96 flex-shrink-0">
|
||||||
|
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 sticky top-28 flex flex-col h-[calc(100vh-140px)]">
|
||||||
|
<div className="p-5 border-b border-gray-100 bg-brand-purple/5 rounded-t-2xl">
|
||||||
|
<h2 className="text-lg font-bold text-brand-purple flex items-center gap-2">
|
||||||
|
<Users size={20} /> Lista Geral de Disponíveis
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
|
Profissionais com status livre no dia {selectedDate.split('-').reverse().join('/')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border-b border-gray-100">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar pelo nome..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={e => setSearchTerm(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-purple outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||||
|
{isProsLoading ? (
|
||||||
|
<div className="flex justify-center items-center h-full">
|
||||||
|
<RefreshCw size={24} className="animate-spin text-brand-purple" />
|
||||||
|
</div>
|
||||||
|
) : paginatedProfessionals.length === 0 ? (
|
||||||
|
<div className="text-center py-10 opacity-60">
|
||||||
|
<UserPlus size={40} className="mx-auto mb-3 text-gray-400" />
|
||||||
|
<p className="text-sm text-gray-500">Nenhum profissional disponível.</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">Todos ocupados ou não encontrados.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
paginatedProfessionals.map(prof => (
|
||||||
|
<div key={prof.id} className="flex flex-col gap-2 p-3 bg-gray-50 border border-gray-100 rounded-xl hover:bg-white hover:border-brand-purple/30 transition-colors">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-3 flex-1 min-w-0 cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
setViewingProfessional(prof);
|
||||||
|
setIsProfModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img src={prof.avatar_url || `https://ui-avatars.com/api/?name=${prof.nome}&background=random`} alt={prof.nome} className="w-10 h-10 rounded-full shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="font-bold text-gray-900 text-sm truncate">{prof.nome}</p>
|
||||||
|
{prof.carro_disponivel && (
|
||||||
|
<Car size={14} className="text-gray-500 shrink-0" title="Possui Carro" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-brand-gold truncate">{prof.role || "Profissional"}</p>
|
||||||
|
{prof.cidade && (
|
||||||
|
<span className={`inline-block mt-1 px-2 py-0.5 text-[10px] uppercase font-bold tracking-wider rounded border ${getCityColor(prof.cidade)}`}>
|
||||||
|
{prof.cidade}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => allocDaily(prof.id)}
|
||||||
|
className="px-4 py-2 bg-[#6E2C90] text-white font-bold rounded-lg text-xs hover:bg-[#5a2375] transition-colors whitespace-nowrap shadow-md border border-[#5a2375]"
|
||||||
|
>
|
||||||
|
Alocar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination Context */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="p-3 bg-gray-50 border-t border-gray-100 flex justify-between items-center rounded-b-2xl">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
className="p-1 rounded bg-white border border-gray-300 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-gray-500 font-medium">Página {page} de {totalPages}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page === totalPages}
|
||||||
|
className="p-1 rounded bg-white border border-gray-300 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<ChevronRight size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Profissionais */}
|
||||||
|
{isProfModalOpen && viewingProfessional && (
|
||||||
|
<ProfessionalDetailsModal
|
||||||
|
professional={viewingProfessional}
|
||||||
|
isOpen={isProfModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsProfModalOpen(false);
|
||||||
|
setViewingProfessional(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useState, useEffect, useMemo } from "react";
|
import React, { useState, useEffect, useMemo } from "react";
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { UserRole, EventData, EventStatus, EventType, Professional } from "../types";
|
import { UserRole, EventData, EventStatus, EventType, Professional } from "../types";
|
||||||
import { EventTable } from "../components/EventTable";
|
import { EventTable } from "../components/EventTable";
|
||||||
import { EventFiltersBar, EventFilters } from "../components/EventFiltersBar";
|
import { EventFiltersBar, EventFilters } from "../components/EventFiltersBar";
|
||||||
|
|
@ -21,8 +21,9 @@ import {
|
||||||
UserX,
|
UserX,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Star,
|
Star,
|
||||||
|
Car, // Added Car icon
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { setCoordinator, finalizeFOT, getPrice } from "../services/apiService";
|
import { setCoordinator, finalizeFOT, getPrice, getDailyInvitationsProfessional, respondDailyInvitation, getDailyInvitationsByDate } from "../services/apiService";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
import { useData } from "../contexts/DataContext";
|
import { useData } from "../contexts/DataContext";
|
||||||
import { STATUS_COLORS } from "../constants";
|
import { STATUS_COLORS } from "../constants";
|
||||||
|
|
@ -37,6 +38,7 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
||||||
}) => {
|
}) => {
|
||||||
const { user, token } = useAuth();
|
const { user, token } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
// Extract updateEventDetails from useData
|
// Extract updateEventDetails from useData
|
||||||
const {
|
const {
|
||||||
events,
|
events,
|
||||||
|
|
@ -54,7 +56,37 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
||||||
refreshEvents,
|
refreshEvents,
|
||||||
} = useData();
|
} = useData();
|
||||||
|
|
||||||
// ... (inside component)
|
const [dailyInvitations, setDailyInvitations] = useState<any[]>([]);
|
||||||
|
const [isLoadingInvites, setIsLoadingInvites] = useState(false);
|
||||||
|
|
||||||
|
const fetchDailyInvitations = async () => {
|
||||||
|
if (user.role !== UserRole.PHOTOGRAPHER && user.role !== UserRole.AGENDA_VIEWER) return;
|
||||||
|
const currentProf = professionals.find(p => p.usuarioId === user.id);
|
||||||
|
if (!currentProf) return;
|
||||||
|
|
||||||
|
setIsLoadingInvites(true);
|
||||||
|
try {
|
||||||
|
const resp = await getDailyInvitationsProfessional(currentProf.id);
|
||||||
|
setDailyInvitations(resp || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingInvites(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (professionals.length > 0) fetchDailyInvitations();
|
||||||
|
}, [user, professionals]);
|
||||||
|
|
||||||
|
const handleRespondDailyInvitation = async (id: string, status: "ACEITO" | "REJEITADO") => {
|
||||||
|
try {
|
||||||
|
await respondDailyInvitation(id, status);
|
||||||
|
fetchDailyInvitations();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error responding to invitation", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSaveEvent = async (data: any) => {
|
const handleSaveEvent = async (data: any) => {
|
||||||
const isClient = user.role === UserRole.EVENT_OWNER;
|
const isClient = user.role === UserRole.EVENT_OWNER;
|
||||||
|
|
@ -83,6 +115,8 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
||||||
name: data.observacoes_evento || selectedEvent.name,
|
name: data.observacoes_evento || selectedEvent.name,
|
||||||
briefing: data.observacoes_evento || selectedEvent.briefing,
|
briefing: data.observacoes_evento || selectedEvent.briefing,
|
||||||
time: data.horario || selectedEvent.time,
|
time: data.horario || selectedEvent.time,
|
||||||
|
startTime: data.horario || selectedEvent.startTime,
|
||||||
|
endTime: data.horario_fim || selectedEvent.endTime || selectedEvent.horario_fim,
|
||||||
};
|
};
|
||||||
setSelectedEvent(updatedEvent);
|
setSelectedEvent(updatedEvent);
|
||||||
setView("details");
|
setView("details");
|
||||||
|
|
@ -108,12 +142,12 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const [view, setView] = useState<"list" | "create" | "edit" | "details">(() => {
|
const [view, setView] = useState<"list" | "create" | "edit" | "details">(() => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
return params.get("eventId") ? "details" : initialView;
|
return params.get("eventId") ? "details" : initialView;
|
||||||
});
|
});
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [selectedEvent, setSelectedEvent] = useState<EventData | null>(() => {
|
const [selectedEvent, setSelectedEvent] = useState<EventData | null>(() => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
const eventId = params.get("eventId");
|
const eventId = params.get("eventId");
|
||||||
if (eventId && events.length > 0) {
|
if (eventId && events.length > 0) {
|
||||||
return events.find(e => e.id === eventId) || null;
|
return events.find(e => e.id === eventId) || null;
|
||||||
|
|
@ -123,17 +157,27 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
||||||
|
|
||||||
// Effect to sync selectedEvent if events load LATER than initial render
|
// Effect to sync selectedEvent if events load LATER than initial render
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
const eventId = params.get("eventId");
|
const eventId = params.get("eventId");
|
||||||
if (eventId && events.length > 0 && !selectedEvent) {
|
if (eventId && events.length > 0) {
|
||||||
const found = events.find(e => e.id === eventId);
|
const found = events.find(e => e.id === eventId);
|
||||||
if (found) {
|
if (found && (!selectedEvent || selectedEvent.id !== eventId)) {
|
||||||
setSelectedEvent(found);
|
setSelectedEvent(found);
|
||||||
// Ensure view is details if we just found it
|
// Ensure view is details if we just found it
|
||||||
setView("details");
|
setView("details");
|
||||||
|
} else if (!found && !isLoading) {
|
||||||
|
// Se não encontrou o evento e já acabou de carregar do servidor, volta pra lista e limpa a URL
|
||||||
|
const newParams = new URLSearchParams(location.search);
|
||||||
|
newParams.delete("eventId");
|
||||||
|
navigate({ search: newParams.toString() }, { replace: true });
|
||||||
|
setSelectedEvent(null);
|
||||||
|
setView("list");
|
||||||
}
|
}
|
||||||
|
} else if (!eventId && selectedEvent) {
|
||||||
|
setSelectedEvent(null);
|
||||||
|
setView(initialView);
|
||||||
}
|
}
|
||||||
}, [events, window.location.search]);
|
}, [events, location.search, selectedEvent, initialView, isLoading, navigate]);
|
||||||
const [activeFilter, setActiveFilter] = useState<string>("all");
|
const [activeFilter, setActiveFilter] = useState<string>("all");
|
||||||
const [advancedFilters, setAdvancedFilters] = useState<EventFilters>({
|
const [advancedFilters, setAdvancedFilters] = useState<EventFilters>({
|
||||||
date: "",
|
date: "",
|
||||||
|
|
@ -150,9 +194,39 @@ 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 [showOnlyDailyAccepted, setShowOnlyDailyAccepted] = useState(false);
|
||||||
|
const [eventDailyInvitations, setEventDailyInvitations] = useState<any[]>([]);
|
||||||
const [roleSelectionProf, setRoleSelectionProf] = useState<Professional | null>(null);
|
const [roleSelectionProf, setRoleSelectionProf] = useState<Professional | null>(null);
|
||||||
const [basePrice, setBasePrice] = useState<number | null>(null);
|
const [basePrice, setBasePrice] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Utility para cores de cidade baseado na planilha do cliente + cores dinâmicas
|
||||||
|
const getCityColor = (city: string | undefined) => {
|
||||||
|
if (!city) return 'bg-gray-100 text-gray-700 border-gray-200'; // Default
|
||||||
|
const c = city.toLowerCase().trim();
|
||||||
|
if (c.includes('campinas')) return 'bg-pink-100 text-pink-700 border-pink-200';
|
||||||
|
if (c.includes('piracicaba')) return 'bg-cyan-100 text-cyan-700 border-cyan-200';
|
||||||
|
if (c.includes('paulo') || c.includes('sp')) return 'bg-slate-200 text-slate-700 border-slate-300';
|
||||||
|
if (c.includes('americana') || c.includes('sbo') || c.includes('barbara') || c.includes('bárbara') || c.includes('santa barbara')) return 'bg-green-100 text-green-700 border-green-200';
|
||||||
|
if (c.includes('indaiatuba')) return 'bg-yellow-100 text-yellow-700 border-yellow-200';
|
||||||
|
|
||||||
|
// Dynamic color for others
|
||||||
|
const palette = [
|
||||||
|
'bg-purple-100 text-purple-700 border-purple-200',
|
||||||
|
'bg-indigo-100 text-indigo-700 border-indigo-200',
|
||||||
|
'bg-teal-100 text-teal-700 border-teal-200',
|
||||||
|
'bg-orange-100 text-orange-700 border-orange-200',
|
||||||
|
'bg-rose-100 text-rose-700 border-rose-200',
|
||||||
|
'bg-violet-100 text-violet-700 border-violet-200',
|
||||||
|
'bg-emerald-100 text-emerald-700 border-emerald-200',
|
||||||
|
'bg-fuchsia-100 text-fuchsia-700 border-fuchsia-200'
|
||||||
|
];
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < c.length; i++) {
|
||||||
|
hash = c.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
}
|
||||||
|
return palette[Math.abs(hash) % palette.length];
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchBasePrice = async () => {
|
const fetchBasePrice = async () => {
|
||||||
if (!selectedEvent || !user || !token) {
|
if (!selectedEvent || !user || !token) {
|
||||||
|
|
@ -244,11 +318,12 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialView) {
|
const params = new URLSearchParams(location.search);
|
||||||
|
if (initialView && !params.get("eventId")) {
|
||||||
setView(initialView);
|
setView(initialView);
|
||||||
if (initialView === "create") setSelectedEvent(null);
|
if (initialView === "create") setSelectedEvent(null);
|
||||||
}
|
}
|
||||||
}, [initialView]);
|
}, [initialView, location.search]);
|
||||||
|
|
||||||
const handleViewProfessional = (professional: Professional) => {
|
const handleViewProfessional = (professional: Professional) => {
|
||||||
setViewingProfessional(professional);
|
setViewingProfessional(professional);
|
||||||
|
|
@ -362,6 +437,20 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchEventDailyInvitations = async () => {
|
||||||
|
if (isTeamModalOpen && selectedEvent && selectedEvent.date) {
|
||||||
|
try {
|
||||||
|
const res = await getDailyInvitationsByDate(selectedEvent.date.split('T')[0]);
|
||||||
|
setEventDailyInvitations(res || []);
|
||||||
|
} catch(err) {
|
||||||
|
console.error("Erro ao buscar convites do dia do evento:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchEventDailyInvitations();
|
||||||
|
}, [isTeamModalOpen, selectedEvent]);
|
||||||
|
|
||||||
// Função para fechar modal de equipe e limpar filtros
|
// Função para fechar modal de equipe e limpar filtros
|
||||||
const closeTeamModal = () => {
|
const closeTeamModal = () => {
|
||||||
setIsTeamModalOpen(false);
|
setIsTeamModalOpen(false);
|
||||||
|
|
@ -369,21 +458,43 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
||||||
setTeamRoleFilter("all");
|
setTeamRoleFilter("all");
|
||||||
setTeamStatusFilter("all");
|
setTeamStatusFilter("all");
|
||||||
setTeamAvailabilityFilter("all");
|
setTeamAvailabilityFilter("all");
|
||||||
|
setShowOnlyDailyAccepted(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const [processingIds, setProcessingIds] = useState<Set<string>>(new Set());
|
const [processingIds, setProcessingIds] = useState<Set<string>>(new Set());
|
||||||
const [teamPage, setTeamPage] = useState(1);
|
const [teamPage, setTeamPage] = useState(1);
|
||||||
const ITEMS_PER_PAGE = 20;
|
const ITEMS_PER_PAGE = 20;
|
||||||
|
|
||||||
// Optimização 1: Pre-calcular status de ocupação (remove complexidade O(N*M) de dentro do loop)
|
// Optimização 1: Pre-calcular status de ocupação com suporte a colisão de horários reais
|
||||||
const busyProfessionalIds = useMemo(() => {
|
const busyProfessionalIds = useMemo(() => {
|
||||||
if (!selectedEvent) return new Set<string>();
|
if (!selectedEvent) return new Set<string>();
|
||||||
const busySet = new Set<string>();
|
const busySet = new Set<string>();
|
||||||
|
|
||||||
|
const parseTime = (timeStr?: string) => {
|
||||||
|
if (!timeStr) return null;
|
||||||
|
const [hours, minutes] = timeStr.split(':').map(Number);
|
||||||
|
if (isNaN(hours) || isNaN(minutes)) return null;
|
||||||
|
return hours * 60 + minutes;
|
||||||
|
};
|
||||||
|
|
||||||
|
const targetStart = parseTime(selectedEvent.startTime || selectedEvent.time);
|
||||||
|
const targetEnd = parseTime(selectedEvent.endTime || selectedEvent.horario_fim);
|
||||||
|
|
||||||
// Itera eventos apenas UMA vez
|
// Itera eventos apenas UMA vez
|
||||||
for (const e of events) {
|
for (const e of events) {
|
||||||
if (e.id !== selectedEvent.id && e.date === selectedEvent.date) {
|
if (e.id !== selectedEvent.id && e.date === selectedEvent.date) {
|
||||||
if (e.assignments) {
|
|
||||||
|
const eStart = parseTime(e.startTime || e.time);
|
||||||
|
const eEnd = parseTime(e.endTime || e.horario_fim);
|
||||||
|
|
||||||
|
let isOverlapping = true; // Por padrão, se não tiver horário, bloqueia o dia todo (comportamento seguro legado)
|
||||||
|
|
||||||
|
if (targetStart !== null && targetEnd !== null && eStart !== null && eEnd !== null) {
|
||||||
|
// Checagem estrita de colisão: O novo começa antes do agendado terminar E termina depois do agendado começar
|
||||||
|
isOverlapping = (targetStart < eEnd) && (targetEnd > eStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOverlapping && e.assignments) {
|
||||||
for (const a of e.assignments) {
|
for (const a of e.assignments) {
|
||||||
if (a.status === 'ACEITO') {
|
if (a.status === 'ACEITO') {
|
||||||
busySet.add(a.professionalId);
|
busySet.add(a.professionalId);
|
||||||
|
|
@ -454,6 +565,12 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filtro "Confirmados pro Plantão" Exclusivo
|
||||||
|
if (showOnlyDailyAccepted) {
|
||||||
|
const hasAcceptedDaily = eventDailyInvitations.some(inv => inv.profissional_id === professional.id && inv.status === 'ACEITO');
|
||||||
|
if (!hasAcceptedDaily) return false;
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
|
|
@ -464,6 +581,8 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
||||||
teamRoleFilter,
|
teamRoleFilter,
|
||||||
teamStatusFilter,
|
teamStatusFilter,
|
||||||
teamAvailabilityFilter,
|
teamAvailabilityFilter,
|
||||||
|
eventDailyInvitations,
|
||||||
|
showOnlyDailyAccepted,
|
||||||
busyProfessionalIds // Depende apenas do Set otimizado
|
busyProfessionalIds // Depende apenas do Set otimizado
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -827,7 +946,7 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
||||||
<div className="min-h-screen bg-gray-50 pt-20 sm:pt-24 md:pt-28 lg:pt-32 pb-8 sm:pb-12 px-3 sm:px-4 lg:px-6">
|
<div className="min-h-screen bg-gray-50 pt-20 sm:pt-24 md:pt-28 lg:pt-32 pb-8 sm:pb-12 px-3 sm:px-4 lg:px-6">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
{view === "list" && !new URLSearchParams(window.location.search).get("eventId") && (
|
{view === "list" && !new URLSearchParams(location.search).get("eventId") && (
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between mb-6 sm:mb-8 gap-3 sm:gap-4 fade-in">
|
<div className="flex flex-col md:flex-row md:items-center justify-between mb-6 sm:mb-8 gap-3 sm:gap-4 fade-in">
|
||||||
{renderRoleSpecificHeader()}
|
{renderRoleSpecificHeader()}
|
||||||
{renderRoleSpecificActions()}
|
{renderRoleSpecificActions()}
|
||||||
|
|
@ -835,8 +954,80 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Content Switcher */}
|
{/* Content Switcher */}
|
||||||
{view === "list" && !new URLSearchParams(window.location.search).get("eventId") && (
|
{view === "list" && !new URLSearchParams(location.search).get("eventId") && (
|
||||||
<div className="space-y-6 fade-in">
|
<div className="space-y-6 fade-in">
|
||||||
|
{user.role === UserRole.PHOTOGRAPHER && dailyInvitations.some(c => c.status === 'PENDENTE') && (
|
||||||
|
<div className="bg-white border rounded-lg overflow-hidden shadow-sm border-brand-purple/20 mb-6">
|
||||||
|
<div className="bg-gradient-to-r from-brand-purple/10 to-transparent p-4 border-b border-brand-purple/10">
|
||||||
|
<h3 className="font-bold text-brand-purple flex items-center gap-2">
|
||||||
|
<Calendar size={18} /> Convites Pendentes
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-600 mt-1">Você foi selecionado para atuar nestas datas. Confirme sua disponibilidade.</p>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{dailyInvitations.filter(c => c.status === 'PENDENTE').map(inv => (
|
||||||
|
<div key={inv.id} className="p-4 flex flex-col sm:flex-row items-center justify-between gap-4 hover:bg-gray-50/50">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-brand-purple/10 text-brand-purple flex items-center justify-center font-bold">
|
||||||
|
{inv.data ? inv.data.split('-')[2] : '--'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-gray-900">{inv.data ? inv.data.split('-').reverse().join('/') : ''}</p>
|
||||||
|
<p className="text-xs text-brand-gold">Plantão Diário</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 w-full sm:w-auto">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleRespondDailyInvitation(inv.id, 'REJEITADO')}
|
||||||
|
className="flex-1 sm:flex-none text-red-600 border-red-200 hover:bg-red-50 hover:border-red-300"
|
||||||
|
>
|
||||||
|
<X size={16} className="mr-1" /> Recusar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleRespondDailyInvitation(inv.id, 'ACEITO')}
|
||||||
|
className="flex-1 sm:flex-none bg-brand-purple hover:bg-brand-purple-dark text-white"
|
||||||
|
>
|
||||||
|
<CheckCircle size={16} className="mr-1" /> Confirmar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{user.role === UserRole.PHOTOGRAPHER && dailyInvitations.some(c => c.status === 'ACEITO') && (
|
||||||
|
<div className="bg-white border rounded-lg overflow-hidden shadow-sm border-brand-gold/20 mb-6">
|
||||||
|
<div className="bg-gradient-to-r from-brand-gold/10 to-transparent p-4 border-b border-brand-gold/10">
|
||||||
|
<h3 className="font-bold text-brand-gold flex items-center gap-2">
|
||||||
|
<CheckCircle size={18} /> Datas Confirmadas
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-600 mt-1">Sua presença está confirmada nestas datas. Aguarde a alocação num evento específico e o envio do briefing.</p>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{dailyInvitations.filter(c => c.status === 'ACEITO').map(inv => (
|
||||||
|
<div key={inv.id} className="p-4 flex flex-col sm:flex-row items-center justify-between gap-4 hover:bg-gray-50/50">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-brand-gold/10 text-brand-gold flex items-center justify-center font-bold">
|
||||||
|
{inv.data ? inv.data.split('-')[2] : '--'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-gray-900">{inv.data ? inv.data.split('-').reverse().join('/') : ''}</p>
|
||||||
|
<p className="text-xs text-brand-purple mt-0.5 font-medium"><Clock size={12} className="inline mr-1 mb-0.5"/> Aguardando Definição de Local Turma</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 w-full sm:w-auto">
|
||||||
|
<span className="px-3 py-1 flex items-center text-sm font-bold bg-green-100 text-green-700 rounded-lg whitespace-nowrap">
|
||||||
|
<CheckCircle size={16} className="mr-2" /> Agendado
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Search Bar */}
|
{/* Search Bar */}
|
||||||
<div className="flex flex-col sm:flex-row gap-4 items-center justify-between bg-gray-50 p-3 rounded-lg border border-gray-100">
|
<div className="flex flex-col sm:flex-row gap-4 items-center justify-between bg-gray-50 p-3 rounded-lg border border-gray-100">
|
||||||
<div className="relative flex-1 w-full">
|
<div className="relative flex-1 w-full">
|
||||||
|
|
@ -899,8 +1090,7 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
||||||
<EventTable
|
<EventTable
|
||||||
events={filteredEvents}
|
events={filteredEvents}
|
||||||
onEventClick={(event) => {
|
onEventClick={(event) => {
|
||||||
setSelectedEvent(event);
|
navigate(`?eventId=${event.id}`);
|
||||||
setView("details");
|
|
||||||
}}
|
}}
|
||||||
onApprove={handleApprove}
|
onApprove={handleApprove}
|
||||||
onReject={handleReject}
|
onReject={handleReject}
|
||||||
|
|
@ -926,7 +1116,7 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
||||||
|
|
||||||
|
|
||||||
{/* Loading State for Deep Link */ }
|
{/* Loading State for Deep Link */ }
|
||||||
{(!!new URLSearchParams(window.location.search).get("eventId") && (!selectedEvent || view !== "details")) && (
|
{(!!new URLSearchParams(location.search).get("eventId") && !selectedEvent) && (
|
||||||
<div className="flex flex-col items-center justify-center py-20 fade-in">
|
<div className="flex flex-col items-center justify-center py-20 fade-in">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-purple mb-4"></div>
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-purple mb-4"></div>
|
||||||
<p className="text-gray-500">Carregando detalhes do evento...</p>
|
<p className="text-gray-500">Carregando detalhes do evento...</p>
|
||||||
|
|
@ -937,7 +1127,12 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
||||||
<div className="fade-in">
|
<div className="fade-in">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => setView("list")}
|
onClick={() => {
|
||||||
|
const newParams = new URLSearchParams(location.search);
|
||||||
|
newParams.delete("eventId");
|
||||||
|
navigate({ search: newParams.toString() });
|
||||||
|
setView("list");
|
||||||
|
}}
|
||||||
className="mb-4 pl-0"
|
className="mb-4 pl-0"
|
||||||
>
|
>
|
||||||
← Voltar para lista
|
← Voltar para lista
|
||||||
|
|
@ -972,7 +1167,8 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
||||||
{new Date(
|
{new Date(
|
||||||
selectedEvent.date + "T00:00:00"
|
selectedEvent.date + "T00:00:00"
|
||||||
).toLocaleDateString("pt-BR")}{" "}
|
).toLocaleDateString("pt-BR")}{" "}
|
||||||
às {selectedEvent.time}
|
das {selectedEvent.startTime || selectedEvent.time}
|
||||||
|
{selectedEvent.endTime || selectedEvent.horario_fim ? ` às ${selectedEvent.endTime || selectedEvent.horario_fim}` : ''}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<MapPin size={16} className="text-brand-gold" />
|
<MapPin size={16} className="text-brand-gold" />
|
||||||
|
|
@ -1111,7 +1307,8 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
||||||
Horário
|
Horário
|
||||||
</p>
|
</p>
|
||||||
<p className="font-semibold text-gray-900">
|
<p className="font-semibold text-gray-900">
|
||||||
{selectedEvent.time}
|
{selectedEvent.startTime || selectedEvent.time}
|
||||||
|
{selectedEvent.endTime || selectedEvent.horario_fim ? ` - ${selectedEvent.endTime || selectedEvent.horario_fim}` : ''}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1428,7 +1625,8 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
||||||
Horário
|
Horário
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm text-gray-900">
|
<td className="px-4 py-3 text-sm text-gray-900">
|
||||||
{selectedEvent.time}
|
{selectedEvent.startTime || selectedEvent.time}
|
||||||
|
{selectedEvent.endTime || selectedEvent.horario_fim ? ` - ${selectedEvent.endTime || selectedEvent.horario_fim}` : ''}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
|
|
@ -1761,7 +1959,7 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className="flex-1 overflow-auto p-6">
|
<div className="flex-1 overflow-auto p-6">
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600 mb-2">
|
||||||
Profissionais disponíveis para a data{" "}
|
Profissionais disponíveis para a data{" "}
|
||||||
<strong>
|
<strong>
|
||||||
{new Date(
|
{new Date(
|
||||||
|
|
@ -1770,6 +1968,16 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
||||||
</strong>
|
</strong>
|
||||||
. Clique em "Adicionar" para atribuir ao evento.
|
. Clique em "Adicionar" para atribuir ao evento.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* Totalizador de Plantão */}
|
||||||
|
{eventDailyInvitations.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 bg-brand-gold/10 text-brand-gold-dark border border-brand-gold/20 p-3 rounded-lg">
|
||||||
|
<CheckCircle size={16} className="text-brand-gold" />
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{eventDailyInvitations.filter(i => i.status === 'ACEITO').length} profissional(is) já confirmou(aram) plantão para este dia na Logística Diária.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filtros e Busca */}
|
{/* Filtros e Busca */}
|
||||||
|
|
@ -1824,14 +2032,26 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
||||||
<option value="unavailable">Indisponíveis</option>
|
<option value="unavailable">Indisponíveis</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
{/* Toggle Confirmados no Plantão */}
|
||||||
|
<label className={`flex items-center gap-2 px-3 py-2 border rounded-lg cursor-pointer transition-colors text-sm ${showOnlyDailyAccepted ? 'bg-brand-gold/10 text-brand-gold border-brand-gold/30' : 'bg-white text-gray-600 border-gray-300 hover:bg-gray-50'}`}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded text-brand-gold focus:ring-brand-gold bg-white border-gray-300"
|
||||||
|
checked={showOnlyDailyAccepted}
|
||||||
|
onChange={(e) => setShowOnlyDailyAccepted(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="font-semibold">Confirmados no Plantão</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
{/* Botão limpar filtros */}
|
{/* Botão limpar filtros */}
|
||||||
{(teamSearchTerm || teamRoleFilter !== "all" || teamStatusFilter !== "all" || teamAvailabilityFilter !== "all") && (
|
{(teamSearchTerm || teamRoleFilter !== "all" || teamStatusFilter !== "all" || teamAvailabilityFilter !== "all" || showOnlyDailyAccepted) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setTeamSearchTerm("");
|
setTeamSearchTerm("");
|
||||||
setTeamRoleFilter("all");
|
setTeamRoleFilter("all");
|
||||||
setTeamStatusFilter("all");
|
setTeamStatusFilter("all");
|
||||||
setTeamAvailabilityFilter("all");
|
setTeamAvailabilityFilter("all");
|
||||||
|
setShowOnlyDailyAccepted(false);
|
||||||
}}
|
}}
|
||||||
className="px-3 py-2 text-sm text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition-colors flex items-center gap-1"
|
className="px-3 py-2 text-sm text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition-colors flex items-center gap-1"
|
||||||
>
|
>
|
||||||
|
|
@ -1915,13 +2135,25 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex-1">
|
||||||
<p className="font-semibold text-gray-800 text-sm">
|
<div className="flex items-center gap-2">
|
||||||
{photographer.name || photographer.nome}
|
<p className="font-semibold text-gray-800 text-sm">
|
||||||
</p>
|
{photographer.name || photographer.nome}
|
||||||
<p className="text-xs text-gray-500">
|
</p>
|
||||||
ID: {photographer.id.substring(0, 8)}...
|
{photographer.carro_disponivel && (
|
||||||
</p>
|
<Car size={14} className="text-gray-500 shrink-0" title="Possui Carro" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
ID: {photographer.id.substring(0, 8)}...
|
||||||
|
</p>
|
||||||
|
{photographer.cidade && (
|
||||||
|
<span className={`inline-block px-1.5 py-0.5 text-[9px] uppercase font-bold tracking-widest rounded border ${getCityColor(photographer.cidade)}`}>
|
||||||
|
{photographer.cidade}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -1966,7 +2198,7 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
||||||
{!status && (
|
{!status && (
|
||||||
<span
|
<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"}`}
|
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'})` : ""}
|
title={busyEvent ? `Em evento: ${busyEvent.name} - ${busyEvent.local_evento || 'Local não informado'} (${busyEvent.startTime || busyEvent.time || 'Horário indefinido'}${busyEvent.endTime || busyEvent.horario_fim ? ' às ' + (busyEvent.endTime || busyEvent.horario_fim) : ''})` : ""}
|
||||||
>
|
>
|
||||||
{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"}
|
||||||
|
|
@ -2070,9 +2302,21 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex-1 min-w-0">
|
||||||
<p className="font-medium text-gray-800 text-sm">{photographer.name || photographer.nome}</p>
|
<div className="flex items-center gap-2">
|
||||||
<p className="text-xs text-gray-500">{assignment?.status === "PENDENTE" ? "Convite Pendente" : assignment?.status}</p>
|
<p className="font-medium text-gray-800 text-sm truncate">{photographer.name || photographer.nome}</p>
|
||||||
|
{photographer.carro_disponivel && (
|
||||||
|
<Car size={14} className="text-gray-500 shrink-0" title="Possui Carro" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
<p className="text-xs text-gray-500 truncate">{assignment?.status === "PENDENTE" ? "Convite Pendente" : assignment?.status}</p>
|
||||||
|
{photographer.cidade && (
|
||||||
|
<span className={`inline-block px-1.5 py-0.5 text-[9px] uppercase font-bold tracking-widest rounded border ${getCityColor(photographer.cidade)}`}>
|
||||||
|
{photographer.cidade}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,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 || event.time || "Não definido"}</p>
|
<p className="font-medium text-gray-800">{event.startTime || event.horario || event.time || "Não definido"}{event.endTime || event.horario_fim ? ` - ${event.endTime || event.horario_fim}` : ''}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
|
|
@ -114,7 +114,8 @@ const EventDetails: React.FC = () => {
|
||||||
dataEvento={event.date}
|
dataEvento={event.date}
|
||||||
allowedProfessionals={event.assignments}
|
allowedProfessionals={event.assignments}
|
||||||
onUpdateStats={setCalculatedStats}
|
onUpdateStats={setCalculatedStats}
|
||||||
defaultTime={event.time}
|
defaultTime={event.startTime || event.time}
|
||||||
|
defaultEndTime={event.endTime || event.horario_fim}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Right: Logistics (Carros) - Only visible if user has permission */}
|
{/* Right: Logistics (Carros) - Only visible if user has permission */}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ import {
|
||||||
Upload,
|
Upload,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import ExcelJS from 'exceljs';
|
||||||
|
import { saveAs } from 'file-saver';
|
||||||
|
|
||||||
interface FinancialTransaction {
|
interface FinancialTransaction {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -302,7 +304,17 @@ const Finance: React.FC = () => {
|
||||||
// Default sort is grouped by FOT
|
// Default sort is grouped by FOT
|
||||||
if (!sortConfig) {
|
if (!sortConfig) {
|
||||||
return result.sort((a, b) => {
|
return result.sort((a, b) => {
|
||||||
// Group by FOT (String comparison to handle "20000MG")
|
// Group by Professional Name if Date Filters are active
|
||||||
|
if (dateFilters.startDate || dateFilters.endDate) {
|
||||||
|
const nameA = String(a.nome || "").toLowerCase();
|
||||||
|
const nameB = String(b.nome || "").toLowerCase();
|
||||||
|
if (nameA !== nameB) return nameA.localeCompare(nameB);
|
||||||
|
|
||||||
|
// Secondary sort by date within the same professional
|
||||||
|
return new Date(a.dataRaw || a.data).getTime() - new Date(b.dataRaw || b.data).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default Group by FOT (String comparison to handle "20000MG")
|
||||||
const fotA = String(a.fot || "");
|
const fotA = String(a.fot || "");
|
||||||
const fotB = String(b.fot || "");
|
const fotB = String(b.fot || "");
|
||||||
if (fotA !== fotB) return fotB.localeCompare(fotA, undefined, { numeric: true });
|
if (fotA !== fotB) return fotB.localeCompare(fotA, undefined, { numeric: true });
|
||||||
|
|
@ -815,58 +827,232 @@ const Finance: React.FC = () => {
|
||||||
|
|
||||||
}, [formData.tipoEvento, formData.tipoServico, formData.tabelaFree]);
|
}, [formData.tipoEvento, formData.tipoServico, formData.tabelaFree]);
|
||||||
|
|
||||||
const handleExportCSV = () => {
|
const handleExportExcel = async () => {
|
||||||
if (sortedTransactions.length === 0) {
|
if (sortedTransactions.length === 0) {
|
||||||
alert("Nenhum dado para exportar.");
|
alert("Nenhum dado para exportar.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// CSV Header
|
const workbook = new ExcelJS.Workbook();
|
||||||
const headers = [
|
const worksheet = workbook.addWorksheet("Extrato Financeiro");
|
||||||
"FOT", "Data", "Evento", "Serviço", "Nome", "WhatsApp", "CPF",
|
|
||||||
"Curso", "Instituição", "Ano", "Empresa",
|
// Columns matching the legacy Excel sheet
|
||||||
"Valor Free", "Valor Extra", "Total Pagar", "Data Pagamento", "Pgto OK"
|
worksheet.columns = [
|
||||||
|
{ header: "", key: "fot", width: 10 },
|
||||||
|
{ header: "", key: "data", width: 12 },
|
||||||
|
{ header: "", key: "curso", width: 20 },
|
||||||
|
{ header: "", key: "instituicao", width: 20 },
|
||||||
|
{ header: "", key: "ano", width: 10 },
|
||||||
|
{ header: "", key: "empresa", width: 15 },
|
||||||
|
{ header: "", key: "evento", width: 15 },
|
||||||
|
{ header: "", key: "servico", width: 15 },
|
||||||
|
{ header: "", key: "nome", width: 25 },
|
||||||
|
{ header: "", key: "whatsapp", width: 15 },
|
||||||
|
{ header: "", key: "cpf", width: 16 },
|
||||||
|
{ header: "", key: "tabelaFree", width: 15 },
|
||||||
|
{ header: "", key: "valorFree", width: 15 },
|
||||||
|
{ header: "", key: "valorExtra", width: 15 },
|
||||||
|
{ header: "", key: "descricaoExtra", width: 40 },
|
||||||
|
{ header: "", key: "totalPagar", width: 15 },
|
||||||
|
{ header: "", key: "dataPgto", width: 12 },
|
||||||
|
{ header: "", key: "pgtoOk", width: 10 },
|
||||||
];
|
];
|
||||||
|
|
||||||
// CSV Rows
|
// Standard Headers (Row 1 now)
|
||||||
const rows = sortedTransactions.map(t => [
|
const headerRow = worksheet.addRow([
|
||||||
t.fot,
|
"FOT", "Data", "Curso", "Instituição", "Ano Format.", "Empresa", "Tipo Evento", "Tipo de Serviço",
|
||||||
t.data,
|
"Nome", "WhatsApp", "CPF", "Tabela Free", "Valor Free", "Valor Extra",
|
||||||
t.tipoEvento,
|
"Descrição do Extra", "Total a Pagar", "Data Pgto", "Pgto OK"
|
||||||
t.tipoServico,
|
|
||||||
`"${t.nome}"`, // Quote names to handle commas
|
|
||||||
t.whatsapp ? `="${t.whatsapp}"` : "", // Force string in Excel to avoid scientific notation
|
|
||||||
t.cpf ? `="${t.cpf}"` : "",
|
|
||||||
`"${t.curso}"`,
|
|
||||||
`"${t.instituicao}"`,
|
|
||||||
t.anoFormatura,
|
|
||||||
`"${t.empresa}"`,
|
|
||||||
t.valorFree.toFixed(2).replace('.', ','),
|
|
||||||
t.valorExtra.toFixed(2).replace('.', ','),
|
|
||||||
t.totalPagar.toFixed(2).replace('.', ','),
|
|
||||||
t.dataPgto,
|
|
||||||
t.pgtoOk ? "Sim" : "Não"
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Summation Row
|
// Header Styling (Red #FF0000 based on screenshot 3)
|
||||||
|
headerRow.eachCell((cell, colNumber) => {
|
||||||
|
cell.fill = {
|
||||||
|
type: 'pattern',
|
||||||
|
pattern: 'solid',
|
||||||
|
fgColor: { argb: 'FFFF0000' } // Red Header
|
||||||
|
};
|
||||||
|
cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true };
|
||||||
|
|
||||||
|
// Even the header in the screenshot has Yellow for Total? The screenshot 3 shows pure blue/grey for table headers, let's use the standard blue that matches their columns
|
||||||
|
// Looking at screenshot 3, the columns header is actually a light blue! "A4:R4"
|
||||||
|
cell.fill = {
|
||||||
|
type: 'pattern',
|
||||||
|
pattern: 'solid',
|
||||||
|
fgColor: { argb: 'FF9BC2E6' } // Light Blue from standard excel
|
||||||
|
};
|
||||||
|
cell.font = { bold: true, color: { argb: 'FF000000' }, size: 10 }; // Black text on blue
|
||||||
|
|
||||||
|
if (worksheet.getColumn(colNumber).key === 'totalPagar') {
|
||||||
|
cell.fill = {
|
||||||
|
type: 'pattern',
|
||||||
|
pattern: 'solid',
|
||||||
|
fgColor: { argb: 'FFFFFF00' }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let currentName = "";
|
||||||
|
let groupSum = 0;
|
||||||
|
|
||||||
|
const applyRowColor = (row: ExcelJS.Row) => {
|
||||||
|
for (let colNumber = 1; colNumber <= 18; colNumber++) {
|
||||||
|
const cell = row.getCell(colNumber);
|
||||||
|
const colKey = worksheet.getColumn(colNumber).key as string;
|
||||||
|
|
||||||
|
cell.border = {
|
||||||
|
top: {style:'thin'},
|
||||||
|
left: {style:'thin'},
|
||||||
|
bottom: {style:'thin'},
|
||||||
|
right: {style:'thin'}
|
||||||
|
};
|
||||||
|
cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true };
|
||||||
|
|
||||||
|
if (colKey === 'descricaoExtra' || colKey === 'nome') {
|
||||||
|
cell.alignment = { vertical: 'middle', horizontal: 'left', wrapText: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (colKey === 'totalPagar') {
|
||||||
|
cell.fill = {
|
||||||
|
type: 'pattern',
|
||||||
|
pattern: 'solid',
|
||||||
|
fgColor: { argb: 'FFFFFF00' } // Yellow column
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (["valorFree", "valorExtra", "totalPagar"].includes(colKey)) {
|
||||||
|
cell.alignment = { vertical: 'middle', horizontal: 'right', wrapText: true };
|
||||||
|
cell.numFmt = '"R$" #,##0.00';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (dateFilters.startDate || dateFilters.endDate) {
|
||||||
|
sortedTransactions.forEach((t, i) => {
|
||||||
|
const tName = t.nome || "Sem Nome";
|
||||||
|
|
||||||
|
if (currentName !== "" && tName !== currentName) {
|
||||||
|
// Subtotal Row
|
||||||
|
const subRow = worksheet.addRow({
|
||||||
|
descricaoExtra: `SUBTOTAL ${currentName}`,
|
||||||
|
totalPagar: groupSum
|
||||||
|
});
|
||||||
|
subRow.font = { bold: true };
|
||||||
|
for (let colNumber = 1; colNumber <= 18; colNumber++) {
|
||||||
|
const cell = subRow.getCell(colNumber);
|
||||||
|
const colKey = worksheet.getColumn(colNumber).key as string;
|
||||||
|
|
||||||
|
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFFF00' } }; // Entire row yellow!
|
||||||
|
cell.border = { top: {style:'thin'}, left: {style:'thin'}, bottom: {style:'thin'}, right: {style:'thin'} };
|
||||||
|
cell.alignment = { vertical: 'middle', horizontal: 'right' };
|
||||||
|
if (["valorFree", "valorExtra", "totalPagar"].includes(colKey)) {
|
||||||
|
cell.numFmt = '"R$" #,##0.00';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
groupSum = 0;
|
||||||
|
}
|
||||||
|
if (currentName === "") {
|
||||||
|
currentName = tName;
|
||||||
|
}
|
||||||
|
currentName = tName;
|
||||||
|
groupSum += t.totalPagar;
|
||||||
|
|
||||||
|
const row = worksheet.addRow({
|
||||||
|
fot: t.fot,
|
||||||
|
data: t.data,
|
||||||
|
curso: t.curso,
|
||||||
|
instituicao: t.instituicao,
|
||||||
|
ano: t.anoFormatura,
|
||||||
|
empresa: t.empresa,
|
||||||
|
evento: t.tipoEvento,
|
||||||
|
servico: t.tipoServico,
|
||||||
|
nome: t.nome,
|
||||||
|
whatsapp: t.whatsapp,
|
||||||
|
cpf: t.cpf,
|
||||||
|
tabelaFree: t.tabelaFree,
|
||||||
|
valorFree: t.valorFree,
|
||||||
|
valorExtra: t.valorExtra,
|
||||||
|
descricaoExtra: t.descricaoExtra,
|
||||||
|
totalPagar: t.totalPagar,
|
||||||
|
dataPgto: t.dataPgto,
|
||||||
|
pgtoOk: t.pgtoOk ? "Sim" : "Não"
|
||||||
|
});
|
||||||
|
applyRowColor(row);
|
||||||
|
|
||||||
|
// Final subtotal
|
||||||
|
if (i === sortedTransactions.length - 1) {
|
||||||
|
const subRow = worksheet.addRow({
|
||||||
|
descricaoExtra: `SUBTOTAL ${currentName}`,
|
||||||
|
totalPagar: groupSum
|
||||||
|
});
|
||||||
|
subRow.font = { bold: true };
|
||||||
|
for (let colNumber = 1; colNumber <= 18; colNumber++) {
|
||||||
|
const cell = subRow.getCell(colNumber);
|
||||||
|
const colKey = worksheet.getColumn(colNumber).key as string;
|
||||||
|
|
||||||
|
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFFF00' } };
|
||||||
|
cell.border = { top: {style:'thin'}, left: {style:'thin'}, bottom: {style:'thin'}, right: {style:'thin'} };
|
||||||
|
cell.alignment = { vertical: 'middle', horizontal: 'right' };
|
||||||
|
if (["valorFree", "valorExtra", "totalPagar"].includes(colKey)) {
|
||||||
|
cell.numFmt = '"R$" #,##0.00';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Flat standard export without grouped subtotals if not filtering dates
|
||||||
|
sortedTransactions.forEach(t => {
|
||||||
|
const row = worksheet.addRow({
|
||||||
|
fot: t.fot,
|
||||||
|
data: t.data,
|
||||||
|
evento: t.tipoEvento,
|
||||||
|
servico: t.tipoServico,
|
||||||
|
nome: t.nome,
|
||||||
|
whatsapp: t.whatsapp,
|
||||||
|
cpf: t.cpf,
|
||||||
|
curso: t.curso,
|
||||||
|
instituicao: t.instituicao,
|
||||||
|
ano: t.anoFormatura,
|
||||||
|
empresa: t.empresa,
|
||||||
|
tabelaFree: t.tabelaFree,
|
||||||
|
valorFree: t.valorFree,
|
||||||
|
valorExtra: t.valorExtra,
|
||||||
|
descricaoExtra: t.descricaoExtra,
|
||||||
|
totalPagar: t.totalPagar,
|
||||||
|
dataPgto: t.dataPgto,
|
||||||
|
pgtoOk: t.pgtoOk ? "Sim" : "Não"
|
||||||
|
});
|
||||||
|
applyRowColor(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global Total Row
|
||||||
const totalValue = sortedTransactions.reduce((sum, t) => sum + t.totalPagar, 0);
|
const totalValue = sortedTransactions.reduce((sum, t) => sum + t.totalPagar, 0);
|
||||||
const sumRow = [
|
const sumRow = worksheet.addRow({
|
||||||
"TOTAL", "", "", "", "", "", "", "", "", "", "", "", "",
|
descricaoExtra: "TOTAL GERAL",
|
||||||
totalValue.toFixed(2).replace('.', ','), "", ""
|
totalPagar: totalValue
|
||||||
];
|
});
|
||||||
|
sumRow.font = { bold: true, size: 12 };
|
||||||
|
for (let colNumber = 1; colNumber <= 18; colNumber++) {
|
||||||
|
const cell = sumRow.getCell(colNumber);
|
||||||
|
const colKey = worksheet.getColumn(colNumber).key as string;
|
||||||
|
|
||||||
// Combine
|
if (colKey === 'totalPagar' || colKey === 'descricaoExtra') {
|
||||||
const csvContent = [
|
cell.fill = {
|
||||||
headers.join(";"),
|
type: 'pattern',
|
||||||
...rows.map(r => r.join(";")),
|
pattern: 'solid',
|
||||||
sumRow.join(";")
|
fgColor: { argb: 'FFFFFF00' } // Yellow
|
||||||
].join("\n");
|
};
|
||||||
|
if (colKey === 'totalPagar') {
|
||||||
|
cell.alignment = { vertical: 'middle', horizontal: 'right' };
|
||||||
|
cell.numFmt = '"R$" #,##0.00';
|
||||||
|
} else {
|
||||||
|
cell.alignment = { vertical: 'middle', horizontal: 'right' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create Blob with BOM for Excel UTF-8 support
|
// Save
|
||||||
const blob = new Blob(["\uFEFF" + csvContent], { type: "text/csv;charset=utf-8;" });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
|
|
||||||
// Dynamic Filename
|
|
||||||
let filename = "extrato_financeiro";
|
let filename = "extrato_financeiro";
|
||||||
if (filters.nome) {
|
if (filters.nome) {
|
||||||
filename += `_${filters.nome.trim().replace(/\s+/g, '_').toLowerCase()}`;
|
filename += `_${filters.nome.trim().replace(/\s+/g, '_').toLowerCase()}`;
|
||||||
|
|
@ -883,21 +1069,15 @@ const Finance: React.FC = () => {
|
||||||
if (dateFilters.endDate) {
|
if (dateFilters.endDate) {
|
||||||
filename += `_ate_${formatDateFilename(dateFilters.endDate)}`;
|
filename += `_ate_${formatDateFilename(dateFilters.endDate)}`;
|
||||||
}
|
}
|
||||||
// Fallback or just append timestamp if no specific filters?
|
|
||||||
// Let's always append a short date/time or just date if no filters to avoid overwrites,
|
|
||||||
// but user asked for specific format. Let's stick to user request + date fallback if empty.
|
|
||||||
if (!filters.nome && !dateFilters.startDate && !dateFilters.endDate) {
|
if (!filters.nome && !dateFilters.startDate && !dateFilters.endDate) {
|
||||||
const today = new Date().toLocaleDateString("pt-BR").split("/").join("-"); // DD-MM-YYYY
|
const today = new Date().toLocaleDateString("pt-BR").split("/").join("-");
|
||||||
filename += `_${today}`;
|
filename += `_${today}`;
|
||||||
}
|
}
|
||||||
filename += ".csv";
|
filename += ".xlsx";
|
||||||
|
|
||||||
const link = document.createElement("a");
|
const buffer = await workbook.xlsx.writeBuffer();
|
||||||
link.href = url;
|
const blob = new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
|
||||||
link.setAttribute("download", filename);
|
saveAs(blob, filename);
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calculate Total for Display
|
// Calculate Total for Display
|
||||||
|
|
@ -913,7 +1093,7 @@ const Finance: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleExportCSV}
|
onClick={handleExportExcel}
|
||||||
className="bg-green-600 text-white px-4 py-2 rounded shadow hover:bg-green-700 transition flex items-center gap-2"
|
className="bg-green-600 text-white px-4 py-2 rounded shadow hover:bg-green-700 transition flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<Download size={18} /> Exportar Dados
|
<Download size={18} /> Exportar Dados
|
||||||
|
|
@ -1147,17 +1327,26 @@ const Finance: React.FC = () => {
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
{!loading && sortedTransactions.map((t, index) => {
|
{!loading && sortedTransactions.map((t, index) => {
|
||||||
const isNewFot = index > 0 && t.fot !== sortedTransactions[index - 1].fot;
|
const isDateFilterActive = !!(dateFilters.startDate || dateFilters.endDate);
|
||||||
|
const isNewGroup = isDateFilterActive
|
||||||
|
? index > 0 && t.nome !== sortedTransactions[index - 1].nome
|
||||||
|
: index > 0 && t.fot !== sortedTransactions[index - 1].fot;
|
||||||
|
|
||||||
// Check if this is the last item of the group (or list) to show summary
|
// Check if this is the last item of the group (or list) to show summary
|
||||||
const isLastOfGroup = index === sortedTransactions.length - 1 || t.fot !== sortedTransactions[index + 1].fot;
|
const isLastOfGroup = isDateFilterActive
|
||||||
// Only show summary if sorted by default (which groups by FOT) or explicitly sorted by FOT
|
? index === sortedTransactions.length - 1 || t.nome !== sortedTransactions[index + 1].nome
|
||||||
const showSummary = isLastOfGroup && (!sortConfig || sortConfig.key === 'fot');
|
: index === sortedTransactions.length - 1 || t.fot !== sortedTransactions[index + 1].fot;
|
||||||
|
|
||||||
|
// Only show summary if sorted by default (which groups by FOT) or explicitly sorted by FOT,
|
||||||
|
// OR if Date Filters are active (which groups by Professional Name)
|
||||||
|
const showSummary = isLastOfGroup && (
|
||||||
|
(!sortConfig || sortConfig.key === 'fot') || isDateFilterActive
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={t.id}>
|
<React.Fragment key={t.id}>
|
||||||
<tr
|
<tr
|
||||||
className={`hover:bg-gray-50 cursor-pointer ${isNewFot ? "border-t-[3px] border-gray-400" : ""} ${selectedIds.has(t.id!) ? "bg-blue-50" : ""}`}
|
className={`hover:bg-gray-50 cursor-pointer ${isNewGroup ? "border-t-[3px] border-gray-400" : ""} ${selectedIds.has(t.id!) ? "bg-blue-50" : ""}`}
|
||||||
onClick={() => handleEdit(t)}
|
onClick={() => handleEdit(t)}
|
||||||
>
|
>
|
||||||
<td className="px-3 py-2 text-center" onClick={(e) => e.stopPropagation()}>
|
<td className="px-3 py-2 text-center" onClick={(e) => e.stopPropagation()}>
|
||||||
|
|
@ -1205,12 +1394,12 @@ const Finance: React.FC = () => {
|
||||||
{showSummary && (
|
{showSummary && (
|
||||||
<tr className="bg-gray-100 font-bold text-gray-800 border-b-2 border-gray-300">
|
<tr className="bg-gray-100 font-bold text-gray-800 border-b-2 border-gray-300">
|
||||||
<td colSpan={11} className="px-3 py-2 text-right uppercase text-[10px] tracking-wide text-gray-500">
|
<td colSpan={11} className="px-3 py-2 text-right uppercase text-[10px] tracking-wide text-gray-500">
|
||||||
Total FOT {t.fot}:
|
{isDateFilterActive ? `Subtotal ${t.nome}:` : `Total FOT ${t.fot}:`}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-right text-brand-gold">
|
<td className="px-3 py-2 text-right text-brand-gold">
|
||||||
{/* Calculate sum for this group */}
|
{/* Calculate sum for this group */}
|
||||||
{sortedTransactions
|
{sortedTransactions
|
||||||
.filter(tr => tr.fot === t.fot)
|
.filter(tr => isDateFilterActive ? tr.nome === t.nome : tr.fot === t.fot)
|
||||||
.reduce((sum, curr) => sum + (curr.totalPagar || 0), 0)
|
.reduce((sum, curr) => sum + (curr.totalPagar || 0), 0)
|
||||||
.toFixed(2)}
|
.toFixed(2)}
|
||||||
</td>
|
</td>
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,21 @@ const ProfessionalStatement: React.FC = () => {
|
||||||
const [selectedTransaction, setSelectedTransaction] = useState<FinancialTransactionDTO | null>(null);
|
const [selectedTransaction, setSelectedTransaction] = useState<FinancialTransactionDTO | null>(null);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
|
||||||
|
// Filter States
|
||||||
|
const [filters, setFilters] = useState({
|
||||||
|
data: "",
|
||||||
|
nome: "",
|
||||||
|
tipo: "",
|
||||||
|
empresa: "",
|
||||||
|
status: ""
|
||||||
|
});
|
||||||
|
|
||||||
|
const [dateFilters, setDateFilters] = useState({
|
||||||
|
startDate: "",
|
||||||
|
endDate: ""
|
||||||
|
});
|
||||||
|
const [showDateFilters, setShowDateFilters] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (token) {
|
if (token) {
|
||||||
fetchStatement();
|
fetchStatement();
|
||||||
|
|
@ -82,6 +97,43 @@ const ProfessionalStatement: React.FC = () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// derived filtered state
|
||||||
|
const transactions = data?.transactions || [];
|
||||||
|
const filteredTransactions = transactions.filter(t => {
|
||||||
|
// String Column Filters
|
||||||
|
if (filters.data && !t.data_evento.toLowerCase().includes(filters.data.toLowerCase())) return false;
|
||||||
|
if (filters.nome && !t.nome_evento.toLowerCase().includes(filters.nome.toLowerCase())) return false;
|
||||||
|
if (filters.tipo && !t.tipo_evento.toLowerCase().includes(filters.tipo.toLowerCase())) return false;
|
||||||
|
if (filters.empresa && !t.empresa.toLowerCase().includes(filters.empresa.toLowerCase())) return false;
|
||||||
|
if (filters.status && !t.status.toLowerCase().includes(filters.status.toLowerCase())) return false;
|
||||||
|
|
||||||
|
// Date Range Filter logic
|
||||||
|
if (dateFilters.startDate || dateFilters.endDate) {
|
||||||
|
// Parse DD/MM/YYYY into JS Date if possible
|
||||||
|
const [d, m, y] = t.data_evento.split('/');
|
||||||
|
if (d && m && y) {
|
||||||
|
const eventDateObj = new Date(parseInt(y), parseInt(m) - 1, parseInt(d));
|
||||||
|
|
||||||
|
if (dateFilters.startDate) {
|
||||||
|
const [sy, sm, sd] = dateFilters.startDate.split('-');
|
||||||
|
const startObj = new Date(parseInt(sy), parseInt(sm) - 1, parseInt(sd));
|
||||||
|
if (eventDateObj < startObj) return false;
|
||||||
|
}
|
||||||
|
if (dateFilters.endDate) {
|
||||||
|
const [ey, em, ed] = dateFilters.endDate.split('-');
|
||||||
|
const endObj = new Date(parseInt(ey), parseInt(em) - 1, parseInt(ed));
|
||||||
|
// Set end of day for precise comparison
|
||||||
|
endObj.setHours(23, 59, 59, 999);
|
||||||
|
if (eventDateObj > endObj) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredTotalSum = filteredTransactions.reduce((acc, curr) => acc + curr.valor_recebido, 0);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="p-8 text-center text-gray-500">Carregando extrato...</div>;
|
return <div className="p-8 text-center text-gray-500">Carregando extrato...</div>;
|
||||||
}
|
}
|
||||||
|
|
@ -126,36 +178,103 @@ const ProfessionalStatement: React.FC = () => {
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-100 overflow-hidden">
|
<div className="bg-white rounded-lg shadow-sm border border-gray-100 overflow-hidden">
|
||||||
<div className="p-6 border-b border-gray-100 flex justify-between items-center">
|
<div className="p-6 border-b border-gray-100 flex justify-between items-center">
|
||||||
<h2 className="text-lg font-bold text-gray-900">Histórico de Pagamentos</h2>
|
<h2 className="text-lg font-bold text-gray-900">Histórico de Pagamentos</h2>
|
||||||
<button className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 bg-gray-50 hover:bg-gray-100 rounded-md transition-colors">
|
<div className="flex gap-2">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<button
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
onClick={() => setShowDateFilters(!showDateFilters)}
|
||||||
</svg>
|
className="text-sm text-gray-600 hover:text-gray-900 flex items-center gap-2"
|
||||||
Exportar
|
>
|
||||||
</button>
|
{showDateFilters ? "▼" : "▶"} Filtros Avançados de Data
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Advanced Date Filters */}
|
||||||
|
{showDateFilters && (
|
||||||
|
<div className="bg-white border-b border-gray-100 p-4 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1">Data Início</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="w-full border rounded px-3 py-2 text-sm"
|
||||||
|
value={dateFilters.startDate}
|
||||||
|
onChange={e => setDateFilters({...dateFilters, startDate: e.target.value})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1">Data Final</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="w-full border rounded px-3 py-2 text-sm"
|
||||||
|
value={dateFilters.endDate}
|
||||||
|
onChange={e => setDateFilters({...dateFilters, endDate: e.target.value})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(dateFilters.startDate || dateFilters.endDate) && (
|
||||||
|
<div className="col-span-1 md:col-span-2 flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => setDateFilters({ startDate: "", endDate: "" })}
|
||||||
|
className="text-sm text-red-600 hover:text-red-800 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
Limpar Filtros de Data
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm text-left">
|
<table className="w-full text-sm text-left">
|
||||||
<thead className="bg-gray-50 text-gray-500 font-medium">
|
<thead className="bg-gray-50 text-gray-500 font-medium">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-4">Data Evento</th>
|
<th className="px-6 py-4">
|
||||||
<th className="px-6 py-4">Nome Evento</th>
|
<div className="flex flex-col gap-1">
|
||||||
<th className="px-6 py-4">Tipo Evento</th>
|
<span>Data Evento</span>
|
||||||
<th className="px-6 py-4">Empresa</th>
|
<input className="w-full text-xs box-border border border-gray-200 rounded px-2 py-1 font-normal text-gray-900" placeholder="Filtrar" value={filters.data} onChange={e => setFilters({...filters, data: e.target.value})} />
|
||||||
<th className="px-6 py-4">Valor Recebido</th>
|
</div>
|
||||||
<th className="px-6 py-4">Data Pagamento</th>
|
</th>
|
||||||
<th className="px-6 py-4 text-center">Status</th>
|
<th className="px-6 py-4">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span>Nome Evento</span>
|
||||||
|
<input className="w-full text-xs box-border border border-gray-200 rounded px-2 py-1 font-normal text-gray-900" placeholder="Filtrar" value={filters.nome} onChange={e => setFilters({...filters, nome: e.target.value})} />
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-4">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span>Tipo Evento</span>
|
||||||
|
<input className="w-full text-xs box-border border border-gray-200 rounded px-2 py-1 font-normal text-gray-900" placeholder="Filtrar" value={filters.tipo} onChange={e => setFilters({...filters, tipo: e.target.value})} />
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-4">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span>Empresa</span>
|
||||||
|
<input className="w-full text-xs box-border border border-gray-200 rounded px-2 py-1 font-normal text-gray-900" placeholder="Filtrar" value={filters.empresa} onChange={e => setFilters({...filters, empresa: e.target.value})} />
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-4 align-top">
|
||||||
|
<div className="whitespace-nowrap pt-1">Valor Recebido</div>
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-4 align-top">
|
||||||
|
<div className="whitespace-nowrap pt-1">Data Pagamento</div>
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-4 text-center">
|
||||||
|
<div className="flex flex-col gap-1 justify-center items-center">
|
||||||
|
<span>Status</span>
|
||||||
|
<input className="w-20 text-xs box-border border border-gray-200 rounded px-2 py-1 font-normal text-gray-900 text-center" placeholder="Pago..." value={filters.status} onChange={e => setFilters({...filters, status: e.target.value})} />
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100">
|
<tbody className="divide-y divide-gray-100">
|
||||||
{(data.transactions || []).length === 0 ? (
|
{filteredTransactions.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="px-6 py-8 text-center text-gray-500">
|
<td colSpan={7} className="px-6 py-8 text-center text-gray-500">
|
||||||
Nenhum pagamento registrado.
|
Nenhum pagamento encontrado para os filtros.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
(data.transactions || []).map((t) => (
|
filteredTransactions.map((t) => (
|
||||||
<tr
|
<tr
|
||||||
key={t.id}
|
key={t.id}
|
||||||
className="hover:bg-gray-50 transition-colors cursor-pointer"
|
className="hover:bg-gray-50 transition-colors cursor-pointer"
|
||||||
|
|
@ -179,7 +298,10 @@ const ProfessionalStatement: React.FC = () => {
|
||||||
<tfoot className="bg-gray-50">
|
<tfoot className="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="px-6 py-3 text-xs text-gray-500">
|
<td colSpan={7} className="px-6 py-3 text-xs text-gray-500">
|
||||||
Total de pagamentos: {(data.transactions || []).length}
|
<div className="flex justify-between items-center w-full">
|
||||||
|
<span>Total filtrado: {filteredTransactions.length}</span>
|
||||||
|
<span className="font-bold text-gray-900">Soma Agrupada: {formatCurrency(filteredTotalSum)}</span>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
Camera,
|
Camera,
|
||||||
Video,
|
Video,
|
||||||
UserCheck,
|
UserCheck,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "../components/Button";
|
import { Button } from "../components/Button";
|
||||||
import {
|
import {
|
||||||
|
|
@ -21,10 +22,12 @@ import { useAuth } from "../contexts/AuthContext";
|
||||||
import { Professional } from "../types";
|
import { Professional } from "../types";
|
||||||
import { ProfessionalDetailsModal } from "../components/ProfessionalDetailsModal";
|
import { ProfessionalDetailsModal } from "../components/ProfessionalDetailsModal";
|
||||||
import { ProfessionalModal } from "../components/ProfessionalModal";
|
import { ProfessionalModal } from "../components/ProfessionalModal";
|
||||||
|
import { useData } from "../contexts/DataContext";
|
||||||
|
|
||||||
export const TeamPage: React.FC = () => {
|
export const TeamPage: React.FC = () => {
|
||||||
const { token: contextToken } = useAuth();
|
const { token: contextToken } = useAuth();
|
||||||
const token = contextToken || "";
|
const token = contextToken || "";
|
||||||
|
const { events } = useData(); // Needed to check assignments
|
||||||
|
|
||||||
// Lists
|
// Lists
|
||||||
const [professionals, setProfessionals] = useState<Professional[]>([]);
|
const [professionals, setProfessionals] = useState<Professional[]>([]);
|
||||||
|
|
@ -37,6 +40,7 @@ export const TeamPage: React.FC = () => {
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [roleFilter, setRoleFilter] = useState("all");
|
const [roleFilter, setRoleFilter] = useState("all");
|
||||||
const [ratingFilter, setRatingFilter] = useState("all");
|
const [ratingFilter, setRatingFilter] = useState("all");
|
||||||
|
const [dateFilter, setDateFilter] = useState(""); // "" means All (Lista Livre)
|
||||||
|
|
||||||
// Selection & Modals
|
// Selection & Modals
|
||||||
const [selectedProfessional, setSelectedProfessional] = useState<Professional | null>(null);
|
const [selectedProfessional, setSelectedProfessional] = useState<Professional | null>(null);
|
||||||
|
|
@ -131,11 +135,26 @@ export const TeamPage: React.FC = () => {
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
const matchesDate = (() => {
|
||||||
|
if (!dateFilter) return true; // Lista livre
|
||||||
|
|
||||||
|
// Find if the professional has an accepted assignment on this date
|
||||||
|
const isAcceptedOnDate = events.some(e => {
|
||||||
|
if (!e.date || !e.date.startsWith(dateFilter)) return false;
|
||||||
|
return e.assignments?.some(a =>
|
||||||
|
a.professionalId === p.id &&
|
||||||
|
(a.status === "ACEITO" || a.status === "CONFIRMADO" || a.status === "aceito" || a.status === "confirmado")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return isAcceptedOnDate;
|
||||||
|
})();
|
||||||
|
|
||||||
// if (roleName === "Desconhecido") return false;
|
// if (roleName === "Desconhecido") return false;
|
||||||
|
|
||||||
return matchesSearch && matchesRole && matchesRating;
|
return matchesSearch && matchesRole && matchesRating && matchesDate;
|
||||||
});
|
});
|
||||||
}, [professionals, searchTerm, roleFilter, ratingFilter, roles]);
|
}, [professionals, searchTerm, roleFilter, ratingFilter, dateFilter, events, roles]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 pt-20 sm:pt-24 md:pt-28 lg:pt-32 pb-8 sm:pb-12">
|
<div className="min-h-screen bg-gray-50 pt-20 sm:pt-24 md:pt-28 lg:pt-32 pb-8 sm:pb-12">
|
||||||
|
|
@ -207,36 +226,64 @@ export const TeamPage: React.FC = () => {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col md:flex-row gap-4 items-center">
|
<div className="flex flex-col xl:flex-row gap-4 xl:items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-col md:flex-row gap-4 items-center">
|
||||||
<Filter size={16} className="text-gray-400" />
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-medium text-gray-700">Filtros:</span>
|
<Filter size={16} className="text-gray-400" />
|
||||||
|
<span className="text-sm font-medium text-gray-700">Filtros:</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={roleFilter}
|
||||||
|
onChange={(e) => setRoleFilter(e.target.value)}
|
||||||
|
className="px-3 py-1 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
||||||
|
>
|
||||||
|
<option value="all">Todas as Funções</option>
|
||||||
|
{roles.map(role => (
|
||||||
|
<option key={role.id} value={role.nome}>{role.nome}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={ratingFilter}
|
||||||
|
onChange={(e) => setRatingFilter(e.target.value)}
|
||||||
|
className="px-3 py-1 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
||||||
|
>
|
||||||
|
<option value="all">Todas as Avaliações</option>
|
||||||
|
<option value="5">⭐ 4.5+ Estrelas</option>
|
||||||
|
<option value="4">⭐ 4.0 - 4.4 Estrelas</option>
|
||||||
|
<option value="3">⭐ 3.0 - 3.9 Estrelas</option>
|
||||||
|
<option value="2">⭐ 2.0 - 2.9 Estrelas</option>
|
||||||
|
<option value="1">⭐ 1.0 - 1.9 Estrelas</option>
|
||||||
|
<option value="0">⭐ Menos de 1.0</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<select
|
<div className="flex flex-col sm:flex-row gap-4 sm:items-center border-t sm:border-t-0 pt-4 sm:pt-0 border-gray-200 xl:border-l xl:pl-4">
|
||||||
value={roleFilter}
|
<div className="text-sm font-medium text-gray-700 whitespace-nowrap">Status Refinado:</div>
|
||||||
onChange={(e) => setRoleFilter(e.target.value)}
|
<div className="flex gap-2 items-center bg-gray-50 p-1 rounded-lg border border-gray-200">
|
||||||
className="px-3 py-1 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
<button
|
||||||
>
|
onClick={() => setDateFilter("")}
|
||||||
<option value="all">Todas as Funções</option>
|
className={`px-3 py-1 text-sm rounded-md transition-all ${dateFilter === "" ? 'bg-white shadow-sm font-semibold text-brand-black' : 'text-gray-500 hover:text-gray-700'}`}
|
||||||
{roles.map(role => (
|
>
|
||||||
<option key={role.id} value={role.nome}>{role.nome}</option>
|
Lista Livre (Todos)
|
||||||
))}
|
</button>
|
||||||
</select>
|
<div className="flex items-center">
|
||||||
|
<input
|
||||||
<select
|
type="date"
|
||||||
value={ratingFilter}
|
className={`px-3 py-1 text-sm rounded-md border-0 bg-transparent transition-all focus:ring-0 cursor-pointer ${dateFilter !== "" ? 'bg-brand-purple text-white shadow-sm font-semibold' : 'text-gray-500 hover:text-gray-700 hover:bg-white'}`}
|
||||||
onChange={(e) => setRatingFilter(e.target.value)}
|
value={dateFilter}
|
||||||
className="px-3 py-1 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
onChange={(e) => setDateFilter(e.target.value)}
|
||||||
>
|
title="Selecione um dia para ver apenas os aceitos"
|
||||||
<option value="all">Todas as Avaliações</option>
|
/>
|
||||||
<option value="5">⭐ 4.5+ Estrelas</option>
|
{dateFilter && (
|
||||||
<option value="4">⭐ 4.0 - 4.4 Estrelas</option>
|
<button onClick={() => setDateFilter("")} className="ml-1 p-1 text-red-300 hover:text-red-500 rounded-full" title="Limpar Data">
|
||||||
<option value="3">⭐ 3.0 - 3.9 Estrelas</option>
|
<X size={14} />
|
||||||
<option value="2">⭐ 2.0 - 2.9 Estrelas</option>
|
</button>
|
||||||
<option value="1">⭐ 1.0 - 1.9 Estrelas</option>
|
)}
|
||||||
<option value="0">⭐ Menos de 1.0</option>
|
</div>
|
||||||
</select>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -269,9 +269,7 @@ const CreateUserModal: React.FC<CreateUserModalProps> = ({
|
||||||
>
|
>
|
||||||
<option value="PHOTOGRAPHER">Profissional</option>
|
<option value="PHOTOGRAPHER">Profissional</option>
|
||||||
<option value="EVENT_OWNER">Cliente (Empresa)</option>
|
<option value="EVENT_OWNER">Cliente (Empresa)</option>
|
||||||
<option value="BUSINESS_OWNER">Dono de Negócio</option>
|
<option value="BUSINESS_OWNER">Administrador</option>
|
||||||
<option value="ADMIN">Administrador</option>
|
|
||||||
<option value="AGENDA_VIEWER">Visualizador de Agenda</option>
|
|
||||||
<option value="RESEARCHER">Pesquisador</option>
|
<option value="RESEARCHER">Pesquisador</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1502,3 +1502,119 @@ export const setCoordinator = async (token: string, eventId: string, professiona
|
||||||
return { error: "Network error" };
|
return { error: "Network error" };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --------------- LOGISTICS DAILY INVITATIONS --------------- //
|
||||||
|
|
||||||
|
export async function getAvailableProfessionalsLogistics(date: string, page: number = 1, limit: number = 50, search: string = "") {
|
||||||
|
try {
|
||||||
|
const region = localStorage.getItem("photum_selected_region") || "SP";
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
data: date,
|
||||||
|
page: page.toString(),
|
||||||
|
limit: limit.toString(),
|
||||||
|
search: search
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await apiFetch(`${API_BASE_URL}/api/logistica/disponiveis?${query.toString()}`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-regiao": region,
|
||||||
|
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching daily professionals:`, error);
|
||||||
|
return { data: [], total: 0, page: 1, total_pages: 1 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDailyInvitation(profissionalId: string, data: string) {
|
||||||
|
try {
|
||||||
|
const region = localStorage.getItem("photum_selected_region") || "SP";
|
||||||
|
const response = await apiFetch(`${API_BASE_URL}/api/logistica/convites`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-regiao": region,
|
||||||
|
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ profissional_id: profissionalId, data: data }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errData.error || `Failed to create invitation (status ${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error creating daily invitation:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDailyInvitationsProfessional(profId: string) {
|
||||||
|
try {
|
||||||
|
const region = localStorage.getItem("photum_selected_region") || "SP";
|
||||||
|
const response = await apiFetch(`${API_BASE_URL}/api/logistica/convites?prof_id=${profId}`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"x-regiao": region,
|
||||||
|
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error("Failed to fetch daily invitations");
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching daily invitations:`, error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function respondDailyInvitation(invitationId: string, status: "ACEITO" | "REJEITADO", reason?: string) {
|
||||||
|
try {
|
||||||
|
const region = localStorage.getItem("photum_selected_region") || "SP";
|
||||||
|
const response = await apiFetch(`${API_BASE_URL}/api/logistica/convites/${invitationId}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-regiao": region,
|
||||||
|
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ status, motivo_rejeicao: reason || "" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error("Failed to respond to invitation");
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error responding daily invitation:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDailyInvitationsByDate(date: string) {
|
||||||
|
try {
|
||||||
|
const region = localStorage.getItem("photum_selected_region") || "SP";
|
||||||
|
const response = await apiFetch(`${API_BASE_URL}/api/logistica/convites-por-data?data=${date}`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"x-regiao": region,
|
||||||
|
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error("Failed to fetch daily invitations by date");
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching daily invitations by date:`, error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
130
frontend/services/googleMapsService.ts
Normal file
130
frontend/services/googleMapsService.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
import { MapboxResult } from "./mapboxService";
|
||||||
|
|
||||||
|
// Exporting MapboxResult from mapboxService to keep compatibility
|
||||||
|
// but using Google Maps to fetch the data.
|
||||||
|
|
||||||
|
const GOOGLE_MAPS_KEY = import.meta.env.VITE_GOOGLE_MAPS_KEY || "";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Busca endereços e locais usando a API de Geocoding do Google
|
||||||
|
*/
|
||||||
|
export async function searchGoogleLocation(query: string, country: string = "br"): Promise<MapboxResult[]> {
|
||||||
|
if (!GOOGLE_MAPS_KEY || GOOGLE_MAPS_KEY.includes("YOUR")) {
|
||||||
|
console.warn("⚠️ Google Maps Token não configurado em .env.local");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const encodedQuery = encodeURIComponent(query);
|
||||||
|
const url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodedQuery}&components=country:${country}&language=pt-BR&key=${GOOGLE_MAPS_KEY}`;
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status !== "OK" || !data.results) {
|
||||||
|
if (data.status !== "ZERO_RESULTS") {
|
||||||
|
console.error("Google Maps API Error:", data.status, data.error_message);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.results.map((result: any) => {
|
||||||
|
// Find components
|
||||||
|
const getComponent = (type: string) => {
|
||||||
|
const comp = result.address_components.find((c: any) => c.types.includes(type));
|
||||||
|
return comp ? comp.long_name : "";
|
||||||
|
};
|
||||||
|
const getShortComponent = (type: string) => {
|
||||||
|
const comp = result.address_components.find((c: any) => c.types.includes(type));
|
||||||
|
return comp ? comp.short_name : "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const street = getComponent("route");
|
||||||
|
const number = getComponent("street_number");
|
||||||
|
const city = getComponent("administrative_area_level_2") || getComponent("locality");
|
||||||
|
const state = getShortComponent("administrative_area_level_1");
|
||||||
|
const zip = getComponent("postal_code");
|
||||||
|
|
||||||
|
// Verify if it's a POI by checking the types of the location
|
||||||
|
const isPoi = result.types.includes("establishment") || result.types.includes("stadium") || result.types.includes("point_of_interest");
|
||||||
|
|
||||||
|
let placeName = undefined;
|
||||||
|
let finalStreet = street;
|
||||||
|
|
||||||
|
if (isPoi) {
|
||||||
|
// Obter o nome do estabelecimento do formatted_address (ex: "Allianz Parque, Av. Francisco Matarazzo...")
|
||||||
|
placeName = result.address_components.find((c: any) => c.types.includes("establishment") || c.types.includes("point_of_interest"))?.long_name;
|
||||||
|
if (!placeName && result.formatted_address) {
|
||||||
|
placeName = result.formatted_address.split(",")[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se a query não conseguiu resolver route/street, usa a formatação
|
||||||
|
if (!finalStreet) finalStreet = result.formatted_address;
|
||||||
|
|
||||||
|
return {
|
||||||
|
placeName,
|
||||||
|
description: placeName ? `${placeName} - ${result.formatted_address}` : result.formatted_address,
|
||||||
|
street: finalStreet,
|
||||||
|
number,
|
||||||
|
city,
|
||||||
|
state,
|
||||||
|
zip,
|
||||||
|
lat: result.geometry.location.lat,
|
||||||
|
lng: result.geometry.location.lng,
|
||||||
|
mapLink: `https://www.google.com/maps/search/?api=1&query=${result.geometry.location.lat},${result.geometry.location.lng}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erro ao buscar no Google Maps:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Busca reversa via Google Maps Geocoding
|
||||||
|
*/
|
||||||
|
export async function reverseGeocodeGoogle(lat: number, lng: number): Promise<MapboxResult | null> {
|
||||||
|
if (!GOOGLE_MAPS_KEY) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `https://maps.googleapis.com/maps/api/geocode/json?latlng=${lat},${lng}&language=pt-BR&key=${GOOGLE_MAPS_KEY}`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status === "OK" && data.results.length > 0) {
|
||||||
|
const result = data.results[0];
|
||||||
|
|
||||||
|
const getComponent = (type: string) => {
|
||||||
|
const comp = result.address_components.find((c: any) => c.types.includes(type));
|
||||||
|
return comp ? comp.long_name : "";
|
||||||
|
};
|
||||||
|
const getShortComponent = (type: string) => {
|
||||||
|
const comp = result.address_components.find((c: any) => c.types.includes(type));
|
||||||
|
return comp ? comp.short_name : "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const street = getComponent("route") || result.formatted_address;
|
||||||
|
const number = getComponent("street_number");
|
||||||
|
const city = getComponent("administrative_area_level_2") || getComponent("locality");
|
||||||
|
const state = getShortComponent("administrative_area_level_1");
|
||||||
|
const zip = getComponent("postal_code");
|
||||||
|
|
||||||
|
return {
|
||||||
|
description: result.formatted_address,
|
||||||
|
street,
|
||||||
|
number,
|
||||||
|
city,
|
||||||
|
state,
|
||||||
|
zip,
|
||||||
|
lat,
|
||||||
|
lng,
|
||||||
|
mapLink: `https://www.google.com/maps/search/?api=1&query=${lat},${lng}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erro no reverse geocode do Google:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,9 +16,11 @@ export interface MapboxFeature {
|
||||||
place_type: string[];
|
place_type: string[];
|
||||||
text: string;
|
text: string;
|
||||||
address?: string;
|
address?: string;
|
||||||
|
properties?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MapboxResult {
|
export interface MapboxResult {
|
||||||
|
placeName?: string;
|
||||||
description: string;
|
description: string;
|
||||||
street: string;
|
street: string;
|
||||||
number: string;
|
number: string;
|
||||||
|
|
@ -56,17 +58,11 @@ export async function searchMapboxLocation(
|
||||||
`access_token=${MAPBOX_TOKEN}&` +
|
`access_token=${MAPBOX_TOKEN}&` +
|
||||||
`country=${country}&` +
|
`country=${country}&` +
|
||||||
`language=pt&` +
|
`language=pt&` +
|
||||||
|
`types=poi,address&` +
|
||||||
`limit=10`;
|
`limit=10`;
|
||||||
|
|
||||||
// Add proximity bias based on region
|
// Removed proximity bias to prevent Mapbox from hiding national POIs (like Estádio Pacaembu)
|
||||||
const region = localStorage.getItem("photum_selected_region");
|
// when the user's region is set far away from the POI.
|
||||||
if (region === "MG") {
|
|
||||||
// Belo Horizonteish center
|
|
||||||
url += `&proximity=-43.9378,-19.9208`;
|
|
||||||
} else {
|
|
||||||
// São Pauloish center (Default)
|
|
||||||
url += `&proximity=-46.6333,-23.5505`;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("🔍 Buscando endereço:", query);
|
console.log("🔍 Buscando endereço:", query);
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
|
|
@ -94,9 +90,18 @@ export async function searchMapboxLocation(
|
||||||
const addressMatch =
|
const addressMatch =
|
||||||
feature.address || feature.text.match(/\d+/)?.[0] || "";
|
feature.address || feature.text.match(/\d+/)?.[0] || "";
|
||||||
|
|
||||||
|
let placeName = undefined;
|
||||||
|
let street = feature.text;
|
||||||
|
|
||||||
|
if (feature.place_type.includes("poi")) {
|
||||||
|
placeName = feature.text;
|
||||||
|
street = feature.properties?.address || feature.place_name.split(",")[0];
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
placeName,
|
||||||
description: feature.place_name,
|
description: feature.place_name,
|
||||||
street: feature.text,
|
street,
|
||||||
number: addressMatch,
|
number: addressMatch,
|
||||||
city: place?.text || "",
|
city: place?.text || "",
|
||||||
state: region?.short_code?.replace("BR-", "") || region?.text || "",
|
state: region?.short_code?.replace("BR-", "") || region?.text || "",
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,7 @@ export interface EventData {
|
||||||
name: string;
|
name: string;
|
||||||
date: string;
|
date: string;
|
||||||
time: string; // Mantido por compatibilidade, mas deprecated
|
time: string; // Mantido por compatibilidade, mas deprecated
|
||||||
|
horario_fim?: string; // Novo Horário de Término vindo do Banco
|
||||||
startTime?: string; // Horário de início
|
startTime?: string; // Horário de início
|
||||||
endTime?: string; // Horário de término
|
endTime?: string; // Horário de término
|
||||||
type: EventType;
|
type: EventType;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue