feat: implementa filtro de agenda por usuário e corrige exibição de detalhes

- Backend: Adiciona `user_id` na tabela agenda e implementa queries de filtro por role.
- Frontend(Context): Corrige dependência do `useEffect` para garantir busca correta ao logar.
- Frontend(Context): Melhora mapeamento de dados (Número FOT, Fallback de Nome, Formandos).
- Frontend(UI): Atualiza EventTable e Dashboard para exibir número FOT e dados vinculados corretamente.
- Frontend(Fix): Resolve erros de TypeScript no enum EventStatus.
This commit is contained in:
NANDO9322 2025-12-16 13:44:02 -03:00
parent 49ddaf5096
commit 66c7306553
24 changed files with 1005 additions and 370 deletions

View file

@ -828,6 +828,14 @@ const docTemplate = `{
"cadastro_fot"
],
"summary": "List all FOT records",
"parameters": [
{
"type": "string",
"description": "Filter by Company ID",
"name": "empresa_id",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
@ -2584,6 +2592,9 @@ const docTemplate = `{
"ativo": {
"type": "boolean"
},
"company_id": {
"type": "string"
},
"company_name": {
"type": "string"
},

View file

@ -822,6 +822,14 @@
"cadastro_fot"
],
"summary": "List all FOT records",
"parameters": [
{
"type": "string",
"description": "Filter by Company ID",
"name": "empresa_id",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
@ -2578,6 +2586,9 @@
"ativo": {
"type": "boolean"
},
"company_id": {
"type": "string"
},
"company_name": {
"type": "string"
},

View file

@ -121,6 +121,8 @@ definitions:
properties:
ativo:
type: boolean
company_id:
type: string
company_name:
type: string
email:
@ -963,6 +965,11 @@ paths:
get:
consumes:
- application/json
parameters:
- description: Filter by Company ID
in: query
name: empresa_id
type: string
produces:
- application/json
responses:

View file

@ -33,7 +33,14 @@ func (h *Handler) Create(c *gin.Context) {
return
}
agenda, err := h.service.Create(c.Request.Context(), req)
userIDStr := c.GetString("userID")
userID, err := uuid.Parse(userIDStr)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Usuário não autenticado"})
return
}
agenda, err := h.service.Create(c.Request.Context(), userID, req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erro ao criar agenda: " + err.Error()})
return
@ -52,7 +59,11 @@ func (h *Handler) Create(c *gin.Context) {
// @Failure 500 {object} map[string]string
// @Router /api/agenda [get]
func (h *Handler) List(c *gin.Context) {
agendas, err := h.service.List(c.Request.Context())
userIDStr := c.GetString("userID")
role := c.GetString("role")
userID, _ := uuid.Parse(userIDStr)
agendas, err := h.service.List(c.Request.Context(), userID, role)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erro ao listar agendas: " + err.Error()})
return

View file

@ -59,7 +59,7 @@ func (s *Service) CalculateStatus(fotoFaltante, recepFaltante, cineFaltante int3
return "ERRO"
}
func (s *Service) Create(ctx context.Context, req CreateAgendaRequest) (generated.Agenda, error) {
func (s *Service) Create(ctx context.Context, userID uuid.UUID, req CreateAgendaRequest) (generated.Agenda, error) {
status := s.CalculateStatus(req.FotoFaltante, req.RecepFaltante, req.CineFaltante)
params := generated.CreateAgendaParams{
@ -86,12 +86,64 @@ func (s *Service) Create(ctx context.Context, req CreateAgendaRequest) (generate
CineFaltante: pgtype.Int4{Int32: req.CineFaltante, Valid: true},
LogisticaObservacoes: pgtype.Text{String: req.LogisticaObservacoes, Valid: req.LogisticaObservacoes != ""},
PreVenda: pgtype.Bool{Bool: req.PreVenda, Valid: true},
UserID: pgtype.UUID{Bytes: userID, Valid: true},
}
return s.queries.CreateAgenda(ctx, params)
}
func (s *Service) List(ctx context.Context) ([]generated.ListAgendasRow, error) {
func (s *Service) List(ctx context.Context, userID uuid.UUID, role string) ([]generated.ListAgendasRow, error) {
// If role is CLIENT (cliente), filter by userID
if role == "cliente" || role == "EVENT_OWNER" {
rows, err := s.queries.ListAgendasByUser(ctx, pgtype.UUID{Bytes: userID, Valid: true})
if err != nil {
return nil, err
}
// Convert ListAgendasByUserRow to ListAgendasRow (they are identical structurally but different types in Go)
// To avoid manual conversion if types differ slightly by name but not structure, we might need a common interface or cast.
// Since sqlc generates separate structs, we have to map them.
var result []generated.ListAgendasRow
for _, r := range rows {
result = append(result, generated.ListAgendasRow{
ID: r.ID,
UserID: r.UserID,
FotID: r.FotID,
DataEvento: r.DataEvento,
TipoEventoID: r.TipoEventoID,
ObservacoesEvento: r.ObservacoesEvento,
LocalEvento: r.LocalEvento,
Endereco: r.Endereco,
Horario: r.Horario,
QtdFormandos: r.QtdFormandos,
QtdFotografos: r.QtdFotografos,
QtdRecepcionistas: r.QtdRecepcionistas,
QtdCinegrafistas: r.QtdCinegrafistas,
QtdEstudios: r.QtdEstudios,
QtdPontoFoto: r.QtdPontoFoto,
QtdPontoID: r.QtdPontoID,
QtdPontoDecorado: r.QtdPontoDecorado,
QtdPontosLed: r.QtdPontosLed,
QtdPlataforma360: r.QtdPlataforma360,
StatusProfissionais: r.StatusProfissionais,
FotoFaltante: r.FotoFaltante,
RecepFaltante: r.RecepFaltante,
CineFaltante: r.CineFaltante,
LogisticaObservacoes: r.LogisticaObservacoes,
PreVenda: r.PreVenda,
CriadoEm: r.CriadoEm,
AtualizadoEm: r.AtualizadoEm,
FotNumero: r.FotNumero,
Instituicao: r.Instituicao,
CursoNome: r.CursoNome,
EmpresaNome: r.EmpresaNome,
AnoSemestre: r.AnoSemestre,
ObservacoesFot: r.ObservacoesFot,
TipoEventoNome: r.TipoEventoNome,
})
}
return result, nil
}
return s.queries.ListAgendas(ctx)
}

View file

@ -138,6 +138,7 @@ type userResponse struct {
Ativo bool `json:"ativo"`
Name string `json:"name,omitempty"`
Phone string `json:"phone,omitempty"`
CompanyID string `json:"company_id,omitempty"`
CompanyName string `json:"company_name,omitempty"`
}
@ -186,6 +187,15 @@ func (h *Handler) Login(c *gin.Context) {
MaxAge: 15 * 60, // 15 mins
})
// Handle Nullable Fields
var companyID, companyName string
if user.EmpresaID.Valid {
companyID = uuid.UUID(user.EmpresaID.Bytes).String()
}
if user.EmpresaNome.Valid {
companyName = user.EmpresaNome.String
}
resp := loginResponse{
AccessToken: tokenPair.AccessToken,
ExpiresAt: "2025-...", // logic to calculate if needed, or remove field
@ -194,6 +204,10 @@ func (h *Handler) Login(c *gin.Context) {
Email: user.Email,
Role: user.Role,
Ativo: user.Ativo,
Name: user.Nome,
Phone: user.Whatsapp,
CompanyID: companyID,
CompanyName: companyName,
},
}
@ -571,16 +585,22 @@ func (h *Handler) GetUser(c *gin.Context) {
if user.EmpresaNome.Valid {
empresaNome = user.EmpresaNome.String
}
var empresaID string
if user.EmpresaID.Valid {
empresaID = uuid.UUID(user.EmpresaID.Bytes).String()
}
resp := map[string]interface{}{
"id": uuid.UUID(user.ID.Bytes).String(),
"email": user.Email,
"role": user.Role,
"ativo": user.Ativo,
"created_at": user.CriadoEm.Time,
"name": user.Nome,
"phone": user.Whatsapp,
"company_name": empresaNome,
resp := loginResponse{
User: userResponse{
ID: uuid.UUID(user.ID.Bytes).String(),
Email: user.Email,
Role: user.Role,
Ativo: user.Ativo,
Name: user.Nome,
Phone: user.Whatsapp,
CompanyID: empresaID,
CompanyName: empresaNome,
},
}
c.JSON(http.StatusOK, resp)

View file

@ -103,7 +103,8 @@ type TokenPair struct {
RefreshToken string
}
func (s *Service) Login(ctx context.Context, email, senha string) (*TokenPair, *generated.Usuario, *generated.GetProfissionalByUsuarioIDRow, error) {
func (s *Service) Login(ctx context.Context, email, senha string) (*TokenPair, *generated.GetUsuarioByEmailRow, *generated.GetProfissionalByUsuarioIDRow, error) {
// The query now returns a Row with joined fields, not just Usuario struct
user, err := s.queries.GetUsuarioByEmail(ctx, email)
if err != nil {
return nil, nil, nil, errors.New("invalid credentials")

View file

@ -128,19 +128,50 @@ func (h *Handler) Create(c *gin.Context) {
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param empresa_id query string false "Filter by Company ID"
// @Success 200 {array} CadastroFotResponse
// @Router /api/cadastro-fot [get]
func (h *Handler) List(c *gin.Context) {
empresaID := c.Query("empresa_id")
var response []CadastroFotResponse
if empresaID != "" {
rows, err := h.service.ListByEmpresa(c.Request.Context(), empresaID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Conversion for specific row type
for _, r := range rows {
response = append(response, CadastroFotResponse{
ID: uuid.UUID(r.ID.Bytes).String(),
Fot: r.Fot,
EmpresaID: uuid.UUID(r.EmpresaID.Bytes).String(),
EmpresaNome: r.EmpresaNome,
CursoID: uuid.UUID(r.CursoID.Bytes).String(),
CursoNome: r.CursoNome,
AnoFormaturaID: uuid.UUID(r.AnoFormaturaID.Bytes).String(),
AnoFormaturaLabel: r.AnoFormaturaLabel,
Instituicao: r.Instituicao.String,
Cidade: r.Cidade.String,
Estado: r.Estado.String,
Observacoes: r.Observacoes.String,
GastosCaptacao: fromPgNumeric(r.GastosCaptacao),
PreVenda: r.PreVenda.Bool,
})
}
} else {
rows, err := h.service.List(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
response := []CadastroFotResponse{}
for _, r := range rows {
response = append(response, toListResponse(r))
}
}
c.JSON(http.StatusOK, response)
}

View file

@ -59,6 +59,15 @@ func (s *Service) List(ctx context.Context) ([]generated.ListCadastroFotRow, err
return s.queries.ListCadastroFot(ctx)
}
func (s *Service) ListByEmpresa(ctx context.Context, empresaID string) ([]generated.ListCadastroFotByEmpresaRow, error) {
uuidVal, err := uuid.Parse(empresaID)
if err != nil {
return nil, errors.New("invalid empresa_id")
}
// Note: ListCadastroFotByEmpresaRow is nearly identical to ListCadastroFotRow but we use the generated type
return s.queries.ListCadastroFotByEmpresa(ctx, pgtype.UUID{Bytes: uuidVal, Valid: true})
}
func (s *Service) GetByID(ctx context.Context, id string) (*generated.GetCadastroFotByIDRow, error) {
uuidVal, err := uuid.Parse(id)
if err != nil {

View file

@ -35,10 +35,11 @@ INSERT INTO agenda (
recep_faltante,
cine_faltante,
logistica_observacoes,
pre_venda
pre_venda,
user_id
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23
) RETURNING 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
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24
) RETURNING id, user_id, fot_id, data_evento, tipo_evento_id, observacoes_evento, local_evento, endereco, horario, qtd_formandos, qtd_fotografos, qtd_recepcionistas, qtd_cinegrafistas, qtd_estudios, qtd_ponto_foto, qtd_ponto_id, qtd_ponto_decorado, qtd_pontos_led, qtd_plataforma_360, status_profissionais, foto_faltante, recep_faltante, cine_faltante, logistica_observacoes, pre_venda, criado_em, atualizado_em
`
type CreateAgendaParams struct {
@ -65,6 +66,7 @@ type CreateAgendaParams struct {
CineFaltante pgtype.Int4 `json:"cine_faltante"`
LogisticaObservacoes pgtype.Text `json:"logistica_observacoes"`
PreVenda pgtype.Bool `json:"pre_venda"`
UserID pgtype.UUID `json:"user_id"`
}
func (q *Queries) CreateAgenda(ctx context.Context, arg CreateAgendaParams) (Agenda, error) {
@ -92,10 +94,12 @@ func (q *Queries) CreateAgenda(ctx context.Context, arg CreateAgendaParams) (Age
arg.CineFaltante,
arg.LogisticaObservacoes,
arg.PreVenda,
arg.UserID,
)
var i Agenda
err := row.Scan(
&i.ID,
&i.UserID,
&i.FotID,
&i.DataEvento,
&i.TipoEventoID,
@ -136,7 +140,7 @@ func (q *Queries) DeleteAgenda(ctx context.Context, id pgtype.UUID) error {
}
const getAgenda = `-- name: GetAgenda :one
SELECT 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 FROM agenda
SELECT id, user_id, fot_id, data_evento, tipo_evento_id, observacoes_evento, local_evento, endereco, horario, qtd_formandos, qtd_fotografos, qtd_recepcionistas, qtd_cinegrafistas, qtd_estudios, qtd_ponto_foto, qtd_ponto_id, qtd_ponto_decorado, qtd_pontos_led, qtd_plataforma_360, status_profissionais, foto_faltante, recep_faltante, cine_faltante, logistica_observacoes, pre_venda, criado_em, atualizado_em FROM agenda
WHERE id = $1 LIMIT 1
`
@ -145,6 +149,7 @@ func (q *Queries) GetAgenda(ctx context.Context, id pgtype.UUID) (Agenda, error)
var i Agenda
err := row.Scan(
&i.ID,
&i.UserID,
&i.FotID,
&i.DataEvento,
&i.TipoEventoID,
@ -176,7 +181,7 @@ func (q *Queries) GetAgenda(ctx context.Context, id pgtype.UUID) (Agenda, error)
const listAgendas = `-- name: ListAgendas :many
SELECT
a.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.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,
cf.fot as fot_numero,
cf.instituicao,
c.nome as curso_nome,
@ -195,6 +200,7 @@ ORDER BY a.data_evento
type ListAgendasRow struct {
ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"user_id"`
FotID pgtype.UUID `json:"fot_id"`
DataEvento pgtype.Date `json:"data_evento"`
TipoEventoID pgtype.UUID `json:"tipo_evento_id"`
@ -240,6 +246,119 @@ func (q *Queries) ListAgendas(ctx context.Context) ([]ListAgendasRow, error) {
var i ListAgendasRow
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.FotID,
&i.DataEvento,
&i.TipoEventoID,
&i.ObservacoesEvento,
&i.LocalEvento,
&i.Endereco,
&i.Horario,
&i.QtdFormandos,
&i.QtdFotografos,
&i.QtdRecepcionistas,
&i.QtdCinegrafistas,
&i.QtdEstudios,
&i.QtdPontoFoto,
&i.QtdPontoID,
&i.QtdPontoDecorado,
&i.QtdPontosLed,
&i.QtdPlataforma360,
&i.StatusProfissionais,
&i.FotoFaltante,
&i.RecepFaltante,
&i.CineFaltante,
&i.LogisticaObservacoes,
&i.PreVenda,
&i.CriadoEm,
&i.AtualizadoEm,
&i.FotNumero,
&i.Instituicao,
&i.CursoNome,
&i.EmpresaNome,
&i.AnoSemestre,
&i.ObservacoesFot,
&i.TipoEventoNome,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listAgendasByUser = `-- name: ListAgendasByUser :many
SELECT
a.id, a.user_id, a.fot_id, a.data_evento, a.tipo_evento_id, a.observacoes_evento, a.local_evento, a.endereco, a.horario, a.qtd_formandos, a.qtd_fotografos, a.qtd_recepcionistas, a.qtd_cinegrafistas, a.qtd_estudios, a.qtd_ponto_foto, a.qtd_ponto_id, a.qtd_ponto_decorado, a.qtd_pontos_led, a.qtd_plataforma_360, a.status_profissionais, a.foto_faltante, a.recep_faltante, a.cine_faltante, a.logistica_observacoes, a.pre_venda, a.criado_em, a.atualizado_em,
cf.fot as fot_numero,
cf.instituicao,
c.nome as curso_nome,
e.nome as empresa_nome,
af.ano_semestre,
cf.observacoes as observacoes_fot,
te.nome as tipo_evento_nome
FROM agenda a
JOIN cadastro_fot cf ON a.fot_id = cf.id
JOIN cursos c ON cf.curso_id = c.id
JOIN empresas e ON cf.empresa_id = e.id
JOIN anos_formaturas af ON cf.ano_formatura_id = af.id
JOIN tipos_eventos te ON a.tipo_evento_id = te.id
WHERE a.user_id = $1
ORDER BY a.data_evento
`
type ListAgendasByUserRow struct {
ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"user_id"`
FotID pgtype.UUID `json:"fot_id"`
DataEvento pgtype.Date `json:"data_evento"`
TipoEventoID pgtype.UUID `json:"tipo_evento_id"`
ObservacoesEvento pgtype.Text `json:"observacoes_evento"`
LocalEvento pgtype.Text `json:"local_evento"`
Endereco pgtype.Text `json:"endereco"`
Horario pgtype.Text `json:"horario"`
QtdFormandos pgtype.Int4 `json:"qtd_formandos"`
QtdFotografos pgtype.Int4 `json:"qtd_fotografos"`
QtdRecepcionistas pgtype.Int4 `json:"qtd_recepcionistas"`
QtdCinegrafistas pgtype.Int4 `json:"qtd_cinegrafistas"`
QtdEstudios pgtype.Int4 `json:"qtd_estudios"`
QtdPontoFoto pgtype.Int4 `json:"qtd_ponto_foto"`
QtdPontoID pgtype.Int4 `json:"qtd_ponto_id"`
QtdPontoDecorado pgtype.Int4 `json:"qtd_ponto_decorado"`
QtdPontosLed pgtype.Int4 `json:"qtd_pontos_led"`
QtdPlataforma360 pgtype.Int4 `json:"qtd_plataforma_360"`
StatusProfissionais pgtype.Text `json:"status_profissionais"`
FotoFaltante pgtype.Int4 `json:"foto_faltante"`
RecepFaltante pgtype.Int4 `json:"recep_faltante"`
CineFaltante pgtype.Int4 `json:"cine_faltante"`
LogisticaObservacoes pgtype.Text `json:"logistica_observacoes"`
PreVenda pgtype.Bool `json:"pre_venda"`
CriadoEm pgtype.Timestamptz `json:"criado_em"`
AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"`
FotNumero int32 `json:"fot_numero"`
Instituicao pgtype.Text `json:"instituicao"`
CursoNome string `json:"curso_nome"`
EmpresaNome string `json:"empresa_nome"`
AnoSemestre string `json:"ano_semestre"`
ObservacoesFot pgtype.Text `json:"observacoes_fot"`
TipoEventoNome string `json:"tipo_evento_nome"`
}
func (q *Queries) ListAgendasByUser(ctx context.Context, userID pgtype.UUID) ([]ListAgendasByUserRow, error) {
rows, err := q.db.Query(ctx, listAgendasByUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListAgendasByUserRow
for rows.Next() {
var i ListAgendasByUserRow
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.FotID,
&i.DataEvento,
&i.TipoEventoID,
@ -311,7 +430,7 @@ SET
pre_venda = $24,
atualizado_em = NOW()
WHERE id = $1
RETURNING 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
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
`
type UpdateAgendaParams struct {
@ -371,6 +490,7 @@ func (q *Queries) UpdateAgenda(ctx context.Context, arg UpdateAgendaParams) (Age
var i Agenda
err := row.Scan(
&i.ID,
&i.UserID,
&i.FotID,
&i.DataEvento,
&i.TipoEventoID,

View file

@ -223,6 +223,76 @@ func (q *Queries) ListCadastroFot(ctx context.Context) ([]ListCadastroFotRow, er
return items, nil
}
const listCadastroFotByEmpresa = `-- name: ListCadastroFotByEmpresa :many
SELECT
c.id, c.fot, c.empresa_id, c.curso_id, c.ano_formatura_id, c.instituicao, c.cidade, c.estado, c.observacoes, c.gastos_captacao, c.pre_venda, c.created_at, c.updated_at,
e.nome as empresa_nome,
cur.nome as curso_nome,
a.ano_semestre as ano_formatura_label
FROM cadastro_fot c
JOIN empresas e ON c.empresa_id = e.id
JOIN cursos cur ON c.curso_id = cur.id
JOIN anos_formaturas a ON c.ano_formatura_id = a.id
WHERE c.empresa_id = $1
ORDER BY c.fot DESC
`
type ListCadastroFotByEmpresaRow struct {
ID pgtype.UUID `json:"id"`
Fot int32 `json:"fot"`
EmpresaID pgtype.UUID `json:"empresa_id"`
CursoID pgtype.UUID `json:"curso_id"`
AnoFormaturaID pgtype.UUID `json:"ano_formatura_id"`
Instituicao pgtype.Text `json:"instituicao"`
Cidade pgtype.Text `json:"cidade"`
Estado pgtype.Text `json:"estado"`
Observacoes pgtype.Text `json:"observacoes"`
GastosCaptacao pgtype.Numeric `json:"gastos_captacao"`
PreVenda pgtype.Bool `json:"pre_venda"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
EmpresaNome string `json:"empresa_nome"`
CursoNome string `json:"curso_nome"`
AnoFormaturaLabel string `json:"ano_formatura_label"`
}
func (q *Queries) ListCadastroFotByEmpresa(ctx context.Context, empresaID pgtype.UUID) ([]ListCadastroFotByEmpresaRow, error) {
rows, err := q.db.Query(ctx, listCadastroFotByEmpresa, empresaID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListCadastroFotByEmpresaRow
for rows.Next() {
var i ListCadastroFotByEmpresaRow
if err := rows.Scan(
&i.ID,
&i.Fot,
&i.EmpresaID,
&i.CursoID,
&i.AnoFormaturaID,
&i.Instituicao,
&i.Cidade,
&i.Estado,
&i.Observacoes,
&i.GastosCaptacao,
&i.PreVenda,
&i.CreatedAt,
&i.UpdatedAt,
&i.EmpresaNome,
&i.CursoNome,
&i.AnoFormaturaLabel,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateCadastroFot = `-- name: UpdateCadastroFot :one
UPDATE cadastro_fot SET
fot = $2,

View file

@ -10,6 +10,7 @@ import (
type Agenda struct {
ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"user_id"`
FotID pgtype.UUID `json:"fot_id"`
DataEvento pgtype.Date `json:"data_evento"`
TipoEventoID pgtype.UUID `json:"tipo_evento_id"`

View file

@ -82,13 +82,35 @@ func (q *Queries) DeleteUsuario(ctx context.Context, id pgtype.UUID) error {
}
const getUsuarioByEmail = `-- name: GetUsuarioByEmail :one
SELECT id, email, senha_hash, role, ativo, criado_em, atualizado_em FROM usuarios
WHERE email = $1 LIMIT 1
SELECT u.id, u.email, u.senha_hash, u.role, u.ativo, u.criado_em, u.atualizado_em,
COALESCE(cp.nome, cc.nome, '') as nome,
COALESCE(cp.whatsapp, cc.telefone, '') as whatsapp,
e.id as empresa_id,
e.nome as empresa_nome
FROM usuarios u
LEFT JOIN cadastro_profissionais cp ON u.id = cp.usuario_id
LEFT JOIN cadastro_clientes cc ON u.id = cc.usuario_id
LEFT JOIN empresas e ON cc.empresa_id = e.id
WHERE u.email = $1 LIMIT 1
`
func (q *Queries) GetUsuarioByEmail(ctx context.Context, email string) (Usuario, error) {
type GetUsuarioByEmailRow struct {
ID pgtype.UUID `json:"id"`
Email string `json:"email"`
SenhaHash string `json:"senha_hash"`
Role string `json:"role"`
Ativo bool `json:"ativo"`
CriadoEm pgtype.Timestamptz `json:"criado_em"`
AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"`
Nome string `json:"nome"`
Whatsapp string `json:"whatsapp"`
EmpresaID pgtype.UUID `json:"empresa_id"`
EmpresaNome pgtype.Text `json:"empresa_nome"`
}
func (q *Queries) GetUsuarioByEmail(ctx context.Context, email string) (GetUsuarioByEmailRow, error) {
row := q.db.QueryRow(ctx, getUsuarioByEmail, email)
var i Usuario
var i GetUsuarioByEmailRow
err := row.Scan(
&i.ID,
&i.Email,
@ -97,6 +119,10 @@ func (q *Queries) GetUsuarioByEmail(ctx context.Context, email string) (Usuario,
&i.Ativo,
&i.CriadoEm,
&i.AtualizadoEm,
&i.Nome,
&i.Whatsapp,
&i.EmpresaID,
&i.EmpresaNome,
)
return i, err
}
@ -105,6 +131,7 @@ const getUsuarioByID = `-- name: GetUsuarioByID :one
SELECT u.id, u.email, u.senha_hash, u.role, u.ativo, u.criado_em, u.atualizado_em,
COALESCE(cp.nome, cc.nome, '') as nome,
COALESCE(cp.whatsapp, cc.telefone, '') as whatsapp,
e.id as empresa_id,
e.nome as empresa_nome
FROM usuarios u
LEFT JOIN cadastro_profissionais cp ON u.id = cp.usuario_id
@ -123,6 +150,7 @@ type GetUsuarioByIDRow struct {
AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"`
Nome string `json:"nome"`
Whatsapp string `json:"whatsapp"`
EmpresaID pgtype.UUID `json:"empresa_id"`
EmpresaNome pgtype.Text `json:"empresa_nome"`
}
@ -139,6 +167,7 @@ func (q *Queries) GetUsuarioByID(ctx context.Context, id pgtype.UUID) (GetUsuari
&i.AtualizadoEm,
&i.Nome,
&i.Whatsapp,
&i.EmpresaID,
&i.EmpresaNome,
)
return i, err
@ -190,6 +219,7 @@ const listUsuariosPending = `-- name: ListUsuariosPending :many
SELECT u.id, u.email, u.role, u.ativo, u.criado_em,
COALESCE(cp.nome, cc.nome, '') as nome,
COALESCE(cp.whatsapp, cc.telefone, '') as whatsapp,
e.id as empresa_id,
e.nome as empresa_nome
FROM usuarios u
LEFT JOIN cadastro_profissionais cp ON u.id = cp.usuario_id
@ -207,6 +237,7 @@ type ListUsuariosPendingRow struct {
CriadoEm pgtype.Timestamptz `json:"criado_em"`
Nome string `json:"nome"`
Whatsapp string `json:"whatsapp"`
EmpresaID pgtype.UUID `json:"empresa_id"`
EmpresaNome pgtype.Text `json:"empresa_nome"`
}
@ -227,6 +258,7 @@ func (q *Queries) ListUsuariosPending(ctx context.Context) ([]ListUsuariosPendin
&i.CriadoEm,
&i.Nome,
&i.Whatsapp,
&i.EmpresaID,
&i.EmpresaNome,
); err != nil {
return nil, err

View file

@ -22,9 +22,10 @@ INSERT INTO agenda (
recep_faltante,
cine_faltante,
logistica_observacoes,
pre_venda
pre_venda,
user_id
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24
) RETURNING *;
-- name: GetAgenda :one
@ -49,6 +50,25 @@ JOIN anos_formaturas af ON cf.ano_formatura_id = af.id
JOIN tipos_eventos te ON a.tipo_evento_id = te.id
ORDER BY a.data_evento;
-- name: ListAgendasByUser :many
SELECT
a.*,
cf.fot as fot_numero,
cf.instituicao,
c.nome as curso_nome,
e.nome as empresa_nome,
af.ano_semestre,
cf.observacoes as observacoes_fot,
te.nome as tipo_evento_nome
FROM agenda a
JOIN cadastro_fot cf ON a.fot_id = cf.id
JOIN cursos c ON cf.curso_id = c.id
JOIN empresas e ON cf.empresa_id = e.id
JOIN anos_formaturas af ON cf.ano_formatura_id = af.id
JOIN tipos_eventos te ON a.tipo_evento_id = te.id
WHERE a.user_id = $1
ORDER BY a.data_evento;
-- name: UpdateAgenda :one
UPDATE agenda
SET

View file

@ -17,6 +17,19 @@ JOIN cursos cur ON c.curso_id = cur.id
JOIN anos_formaturas a ON c.ano_formatura_id = a.id
ORDER BY c.fot DESC;
-- name: ListCadastroFotByEmpresa :many
SELECT
c.*,
e.nome as empresa_nome,
cur.nome as curso_nome,
a.ano_semestre as ano_formatura_label
FROM cadastro_fot c
JOIN empresas e ON c.empresa_id = e.id
JOIN cursos cur ON c.curso_id = cur.id
JOIN anos_formaturas a ON c.ano_formatura_id = a.id
WHERE c.empresa_id = $1
ORDER BY c.fot DESC;
-- name: GetCadastroFotByID :one
SELECT
c.*,

View file

@ -4,13 +4,22 @@ VALUES ($1, $2, $3, false)
RETURNING *;
-- name: GetUsuarioByEmail :one
SELECT * FROM usuarios
WHERE email = $1 LIMIT 1;
SELECT u.id, u.email, u.senha_hash, u.role, u.ativo, u.criado_em, u.atualizado_em,
COALESCE(cp.nome, cc.nome, '') as nome,
COALESCE(cp.whatsapp, cc.telefone, '') as whatsapp,
e.id as empresa_id,
e.nome as empresa_nome
FROM usuarios u
LEFT JOIN cadastro_profissionais cp ON u.id = cp.usuario_id
LEFT JOIN cadastro_clientes cc ON u.id = cc.usuario_id
LEFT JOIN empresas e ON cc.empresa_id = e.id
WHERE u.email = $1 LIMIT 1;
-- name: GetUsuarioByID :one
SELECT u.id, u.email, u.senha_hash, u.role, u.ativo, u.criado_em, u.atualizado_em,
COALESCE(cp.nome, cc.nome, '') as nome,
COALESCE(cp.whatsapp, cc.telefone, '') as whatsapp,
e.id as empresa_id,
e.nome as empresa_nome
FROM usuarios u
LEFT JOIN cadastro_profissionais cp ON u.id = cp.usuario_id
@ -26,6 +35,7 @@ WHERE id = $1;
SELECT u.id, u.email, u.role, u.ativo, u.criado_em,
COALESCE(cp.nome, cc.nome, '') as nome,
COALESCE(cp.whatsapp, cc.telefone, '') as whatsapp,
e.id as empresa_id,
e.nome as empresa_nome
FROM usuarios u
LEFT JOIN cadastro_profissionais cp ON u.id = cp.usuario_id

View file

@ -316,6 +316,7 @@ CREATE TABLE IF NOT EXISTS cadastro_clientes (
-- Agenda Table
CREATE TABLE IF NOT EXISTS agenda (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES usuarios(id) ON DELETE CASCADE, -- User who created the event (Client/Owner)
fot_id UUID NOT NULL REFERENCES cadastro_fot(id) ON DELETE CASCADE,
data_evento DATE NOT NULL,
tipo_evento_id UUID NOT NULL REFERENCES tipos_eventos(id),

View file

@ -26,7 +26,7 @@ import { useData } from "../contexts/DataContext";
import { UserRole } from "../types";
import { InstitutionForm } from "./InstitutionForm";
import { MapboxMap } from "./MapboxMap";
import { getEventTypes, EventTypeResponse } from "../services/apiService";
import { getEventTypes, EventTypeResponse, getCadastroFot, createAgenda } from "../services/apiService";
interface EventFormProps {
onCancel: () => void;
@ -39,12 +39,9 @@ export const EventForm: React.FC<EventFormProps> = ({
onSubmit,
initialData,
}) => {
const { user } = useAuth();
const { user, token: userToken } = useAuth();
const {
institutions,
getInstitutionsByUserId,
addInstitution,
getActiveCoursesByInstitutionId,
} = useData();
const [activeTab, setActiveTab] = useState<
"details" | "location" | "briefing" | "files"
@ -55,19 +52,11 @@ export const EventForm: React.FC<EventFormProps> = ({
const [isGeocoding, setIsGeocoding] = useState(false);
const [showToast, setShowToast] = useState(false);
const [showInstitutionForm, setShowInstitutionForm] = useState(false);
const [availableCourses, setAvailableCourses] = useState<any[]>([]);
const [eventTypes, setEventTypes] = useState<EventTypeResponse[]>([]);
const [isBackendDown, setIsBackendDown] = useState(false);
const [isLoadingEventTypes, setIsLoadingEventTypes] = useState(true);
// Get institutions based on user role
// Business owners and admins see all institutions, clients see only their own
const userInstitutions = user
? user.role === UserRole.BUSINESS_OWNER || user.role === UserRole.SUPERADMIN
? institutions
: getInstitutionsByUserId(user.id)
: [];
// Default State or Initial Data
const [formData, setFormData] = useState(
initialData || {
@ -78,6 +67,7 @@ export const EventForm: React.FC<EventFormProps> = ({
endTime: "",
type: "",
status: EventStatus.PLANNING,
locationName: "", // New field: Nome do Local
address: {
street: "",
number: "",
@ -91,9 +81,10 @@ export const EventForm: React.FC<EventFormProps> = ({
briefing: "",
contacts: [{ name: "", role: "", phone: "" }],
files: [] as File[],
institutionId: "",
institutionId: "", // Legacy, might clear or keep for compatibility
attendees: "",
courseId: "",
courseId: "", // Legacy
fotId: "", // New field for FOT linkage
}
);
@ -104,7 +95,13 @@ export const EventForm: React.FC<EventFormProps> = ({
? "Solicitar Orçamento/Evento"
: "Cadastrar Novo Evento";
// Buscar tipos de eventos do backend
const submitLabel = initialData
? "Salvar Alterações"
: isClientRequest
? "Enviar Solicitação"
: "Criar Evento";
// Fetch Event Types
useEffect(() => {
const fetchEventTypes = async () => {
setIsLoadingEventTypes(true);
@ -123,25 +120,63 @@ export const EventForm: React.FC<EventFormProps> = ({
fetchEventTypes();
}, []);
const submitLabel = initialData
? "Salvar Alterações"
: isClientRequest
? "Enviar Solicitação"
: "Criar Evento";
// Carregar cursos disponíveis quando instituição for selecionada
// Fetch FOTs filtered by user company
const [availableFots, setAvailableFots] = useState<any[]>([]);
const [loadingFots, setLoadingFots] = useState(false);
useEffect(() => {
if (formData.institutionId) {
const courses = getActiveCoursesByInstitutionId(formData.institutionId);
setAvailableCourses(courses);
} else {
setAvailableCourses([]);
// Limpa o curso selecionado se a instituição mudar
if (formData.courseId) {
setFormData((prev: any) => ({ ...prev, courseId: "" }));
const loadFots = async () => {
// Allow FOT loading for Business Owners, Event Owners (Clients), and Superadmins
if (user?.role === UserRole.BUSINESS_OWNER || user?.role === UserRole.EVENT_OWNER || user?.role === UserRole.SUPERADMIN) {
// If user is not superadmin (admin generally has no empresaId but sees all or selects one, here we assume superadmin logic is separate or allowed)
// Check if regular user has empresaId
if (user?.role !== UserRole.SUPERADMIN && !user?.empresaId) {
// If no company linked, do not load FOTs
return;
}
setLoadingFots(true);
const token = localStorage.getItem("token") || "";
// Use empresaId from user context if available
const empresaId = user.empresaId;
const response = await getCadastroFot(token, empresaId);
if (response.data) {
// If we didn't filter by API (e.g. no empresaId), filter client side as fallback
const myFots = (empresaId || user.companyName)
? response.data.filter(f =>
(empresaId && f.empresa_id === empresaId) ||
(user.companyName && f.empresa_nome === user.companyName)
)
: response.data;
setAvailableFots(myFots);
}
}, [formData.institutionId, getActiveCoursesByInstitutionId]);
setLoadingFots(false);
}
};
loadFots();
}, [user]);
// Derived state for dropdowns
const [selectedCourseName, setSelectedCourseName] = useState("");
const [selectedInstitutionName, setSelectedInstitutionName] = useState("");
// Unique Courses
const uniqueCourses = Array.from(new Set(availableFots.map(f => f.curso_nome))).sort();
// Filtered Institutions based on Course
const filteredInstitutions = availableFots
.filter(f => f.curso_nome === selectedCourseName)
.map(f => f.instituicao)
.filter((v, i, a) => a.indexOf(v) === i)
.sort();
// Filtered Years based on Course + Inst
const filteredYears = availableFots
.filter(f => f.curso_nome === selectedCourseName && f.instituicao === selectedInstitutionName)
.map(f => ({ id: f.id, label: f.ano_formatura_label }));
// Address Autocomplete Logic using Mapbox
useEffect(() => {
@ -177,7 +212,6 @@ export const EventForm: React.FC<EventFormProps> = ({
};
const handleMapLocationChange = async (lat: number, lng: number) => {
// Buscar endereço baseado nas coordenadas
const addressData = await reverseGeocode(lat, lng);
if (addressData) {
@ -195,7 +229,6 @@ export const EventForm: React.FC<EventFormProps> = ({
},
}));
} else {
// Se não conseguir o endereço, atualiza apenas as coordenadas
setFormData((prev: any) => ({
...prev,
address: {
@ -208,23 +241,16 @@ export const EventForm: React.FC<EventFormProps> = ({
}
};
// Geocoding quando o usuário digita o endereço manualmente
const handleManualAddressChange = async () => {
const { street, number, city, state } = formData.address;
// Montar query de busca
const query = `${street} ${number}, ${city}, ${state}`.trim();
if (query.length < 5) return; // Endereço muito curto
if (query.length < 5) return;
setIsGeocoding(true);
try {
const results = await searchMapboxLocation(query);
if (results.length > 0) {
const firstResult = results[0];
setFormData((prev: any) => ({
...prev,
address: {
@ -264,25 +290,72 @@ export const EventForm: React.FC<EventFormProps> = ({
}
};
const handleSubmit = () => {
// Validate institution selection
if (!formData.institutionId) {
alert("Por favor, selecione uma instituição antes de continuar.");
const handleSubmit = async () => {
// Validation
if (!formData.name) return alert("Preencha o tipo de evento");
if (!formData.date) return alert("Preencha a data");
if (user?.role === UserRole.BUSINESS_OWNER || user?.role === UserRole.EVENT_OWNER) {
if (!formData.fotId) {
alert("Por favor, selecione a Turma (Cadastro FOT) antes de continuar.");
return;
}
// Validate course selection
if (!formData.courseId) {
alert("Por favor, selecione um curso/turma antes de continuar.");
return;
}
// Show toast
try {
setShowToast(true);
// Call original submit after small delay for visual effect or immediately
// Prepare Payload for Agenda API
const payload = {
fot_id: formData.fotId,
tipo_evento_id: formData.typeId || "00000000-0000-0000-0000-000000000000",
data_evento: new Date(formData.date).toISOString(),
horario: formData.startTime || "",
observacoes_evento: formData.briefing || "",
local_evento: formData.locationName || "",
endereco: `${formData.address.street}, ${formData.address.number} - ${formData.address.city}/${formData.address.state}`,
qtd_formandos: parseInt(formData.attendees) || 0,
// Default integer values
qtd_fotografos: 0,
qtd_recepcionistas: 0,
qtd_cinegrafistas: 0,
qtd_estudios: 0,
qtd_ponto_foto: 0,
qtd_ponto_id: 0,
qtd_ponto_decorado: 0,
qtd_pontos_led: 0,
qtd_plataforma_360: 0,
status_profissionais: "PENDING",
foto_faltante: 0,
recep_faltante: 0,
cine_faltante: 0,
logistica_observacoes: "",
pre_venda: true
};
const authToken = userToken || localStorage.getItem("token") || "";
const response = await createAgenda(authToken, payload);
if (response.error) {
alert("Erro ao criar evento: " + response.error);
setShowToast(false);
return;
}
setTimeout(() => {
if (onSubmit) {
onSubmit(formData);
}
// Redirect or close is handled by parent, but we show success via toast usually
alert("Solicitação enviada com sucesso!");
}, 1000);
} catch (e: any) {
console.error(e);
alert("Erro inesperado: " + e.message);
setShowToast(false);
}
};
const handleInstitutionSubmit = (institutionData: any) => {
@ -292,11 +365,10 @@ export const EventForm: React.FC<EventFormProps> = ({
ownerId: user?.id || "",
};
addInstitution(newInstitution);
setFormData((prev) => ({ ...prev, institutionId: newInstitution.id }));
setFormData((prev: any) => ({ ...prev, institutionId: newInstitution.id }));
setShowInstitutionForm(false);
};
// Show institution form modal
if (showInstitutionForm) {
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
@ -337,7 +409,7 @@ export const EventForm: React.FC<EventFormProps> = ({
: "Preencha as informações técnicas do evento."}
</p>
</div>
{/* Step indicators - Hidden on mobile, shown on tablet+ */}
{/* Step indicators */}
<div className="hidden sm:flex space-x-2">
{["details", "location", "briefing", "files"].map((tab, idx) => (
<div
@ -359,7 +431,7 @@ export const EventForm: React.FC<EventFormProps> = ({
</div>
</div>
{/* Mobile Tabs - Horizontal */}
{/* Mobile Tabs */}
<div className="lg:hidden border-b border-gray-200 bg-white overflow-x-auto scrollbar-hide">
<div className="flex min-w-max">
{[
@ -380,11 +452,9 @@ export const EventForm: React.FC<EventFormProps> = ({
: "text-gray-500 border-transparent hover:bg-gray-50"
}`}
>
<span
className="inline-block w-5 h-5 rounded-full text-[10px] leading-5 text-center mr-1.5 ${
<span className="inline-block w-5 h-5 rounded-full text-[10px] leading-5 text-center mr-1.5 ${
activeTab === item.id ? 'bg-brand-gold text-white' : 'bg-gray-200 text-gray-600'
}"
>
}">
{item.icon}
</span>
{item.label}
@ -429,14 +499,22 @@ export const EventForm: React.FC<EventFormProps> = ({
</label>
<select
required
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
value={formData.typeId || ""}
onChange={(e) => {
const selectedId = e.target.value;
const selectedType = eventTypes.find((t) => t.id === selectedId);
setFormData({
...formData,
typeId: selectedId,
type: selectedType?.nome || ""
});
}}
disabled={isLoadingEventTypes || isBackendDown}
className="w-full px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold disabled:bg-gray-100 disabled:cursor-not-allowed"
>
<option value="">Selecione o tipo de evento</option>
{eventTypes.map((type) => (
<option key={type.id} value={type.nome}>
<option key={type.id} value={type.id}>
{type.nome}
</option>
))}
@ -455,8 +533,8 @@ export const EventForm: React.FC<EventFormProps> = ({
</div>
<Input
label="Nome do Evento (Opcional)"
placeholder="Ex: Formatura Educação Física 2025"
label="Observações do Evento (Opcional)"
placeholder="Ex: Cerimônia de Colação de Grau"
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
@ -482,23 +560,21 @@ export const EventForm: React.FC<EventFormProps> = ({
required
/>
<Input
label="Horário de Término*"
label="Horário de Término (Opcional)"
type="time"
value={formData.endTime}
onChange={(e) =>
setFormData({ ...formData, endTime: e.target.value })
}
required
/>
</div>
<Input
label="Número de universitários"
placeholder="Ex: 150"
label="Número de Formandos"
placeholder="Ex: 50"
value={formData.attendees}
onChange={(e) => {
const value = e.target.value;
// Permite apenas números
if (value === "" || /^\d+$/.test(value)) {
setFormData({ ...formData, attendees: value });
}
@ -507,137 +583,85 @@ export const EventForm: React.FC<EventFormProps> = ({
inputMode="numeric"
/>
{/* Institution Selection - OBRIGATÓRIO */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1 tracking-wide uppercase text-xs">
Universidade*{" "}
<span className="text-brand-gold">(Obrigatório)</span>
</label>
{/* Dynamic FOT Selection */}
<div className="bg-gray-50 p-4 rounded-md border border-gray-200">
<h3 className="text-sm font-medium text-gray-700 mb-4 uppercase tracking-wider">Seleção da Turma</h3>
{userInstitutions.length === 0 ? (
<div className="border-2 border-dashed border-amber-300 bg-amber-50 rounded-sm p-4">
<div className="flex items-start space-x-3">
<AlertCircle
className="text-amber-600 flex-shrink-0 mt-0.5"
size={20}
/>
<div className="flex-1">
<p className="text-sm font-medium text-amber-900 mb-2">
Nenhuma universidade cadastrada
</p>
<p className="text-xs text-amber-700 mb-3">
Você precisa cadastrar uma universidade antes de
criar um evento. Trabalhamos exclusivamente com
eventos fotográficos em universidades.
</p>
<button
type="button"
onClick={() => setShowInstitutionForm(true)}
className="text-xs font-bold text-amber-900 hover:text-amber-700 underline flex items-center"
>
<Plus size={14} className="mr-1" />
Cadastrar minha primeira universidade
</button>
{!user?.empresaId && user?.role !== UserRole.SUPERADMIN ? (
<div className="bg-red-50 border-l-4 border-red-400 p-4 mb-4">
<div className="flex">
<div className="flex-shrink-0">
<AlertCircle className="h-5 w-5 text-red-400" aria-hidden="true" />
</div>
</div>
</div>
) : (
<div className="space-y-2">
<select
className="w-full px-4 py-2 border border-gray-300 rounded-sm focus:outline-none focus:ring-1 focus:ring-brand-gold focus:border-brand-gold transition-colors"
value={formData.institutionId}
onChange={(e) =>
setFormData({
...formData,
institutionId: e.target.value,
})
}
required
>
<option value="">Selecione uma universidade</option>
{userInstitutions.map((inst) => (
<option key={inst.id} value={inst.id}>
{inst.name} - {inst.type}
</option>
))}
</select>
<button
type="button"
onClick={() => setShowInstitutionForm(true)}
className="text-xs text-brand-gold hover:underline flex items-center"
>
<Plus size={12} className="mr-1" />
Cadastrar nova universidade
</button>
{formData.institutionId && (
<div className="bg-green-50 border border-green-200 rounded-sm p-3 flex items-center">
<Check size={16} className="text-green-600 mr-2" />
<span className="text-xs text-green-800">
Universidade selecionada com sucesso
</span>
</div>
)}
</div>
)}
</div>
{/* Course Selection - Condicional baseado na instituição */}
{formData.institutionId && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1 tracking-wide uppercase text-xs">
Curso/Turma <span className="text-red-500">*</span>
</label>
{availableCourses.length === 0 ? (
<div className="border-2 border-dashed border-gray-300 bg-gray-50 rounded-sm p-4">
<div className="flex items-start space-x-3">
<AlertCircle
className="text-gray-400 flex-shrink-0 mt-0.5"
size={20}
/>
<div className="flex-1">
<p className="text-sm font-medium text-gray-700 mb-1">
Nenhum curso cadastrado
</p>
<p className="text-xs text-gray-500">
Entre em contato com a administração para
cadastrar os cursos/turmas disponíveis nesta
universidade.
<div className="ml-3">
<p className="text-sm text-red-700">
Sua conta não está vinculada a nenhuma empresa. Por favor, entre em contato com a administração para regularizar seu cadastro antes de solicitar um evento.
</p>
</div>
</div>
</div>
) : (
<>
{/* 1. Curso */}
<div className="mb-4">
<label className="block text-sm text-gray-600 mb-1">Curso</label>
<select
className="w-full px-4 py-2 border border-gray-300 rounded-sm focus:outline-none focus:ring-1 focus:ring-brand-gold focus:border-brand-gold transition-colors"
value={formData.courseId}
onChange={(e) =>
setFormData({
...formData,
courseId: e.target.value,
})
}
required
className="w-full px-3 py-2 border border-gray-300 rounded focus:ring-brand-gold focus:border-brand-gold"
value={selectedCourseName}
onChange={e => {
setSelectedCourseName(e.target.value);
setSelectedInstitutionName("");
setFormData({ ...formData, fotId: "" });
}}
disabled={loadingFots}
>
<option value="">Selecione um curso *</option>
{availableCourses.map((course) => (
<option key={course.id} value={course.id}>
{course.name} - {course.graduationType} (
{course.year}/{course.semester}º sem)
</option>
<option value="">Selecione o Curso</option>
{uniqueCourses.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
{/* 2. Instituição */}
<div className="mb-4">
<label className="block text-sm text-gray-600 mb-1">Instituição</label>
<select
className="w-full px-3 py-2 border border-gray-300 rounded focus:ring-brand-gold focus:border-brand-gold"
value={selectedInstitutionName}
onChange={e => {
setSelectedInstitutionName(e.target.value);
setFormData({ ...formData, fotId: "" });
}}
disabled={!selectedCourseName}
>
<option value="">Selecione a Instituição</option>
{filteredInstitutions.map(i => <option key={i} value={i}>{i}</option>)}
</select>
</div>
{/* 3. Ano/Turma (Final FOT Selection) */}
<div className="mb-0">
<label className="block text-sm text-gray-600 mb-1">Ano/Turma</label>
<select
className="w-full px-3 py-2 border border-gray-300 rounded focus:ring-brand-gold focus:border-brand-gold"
value={formData.fotId || ""}
onChange={e => setFormData({ ...formData, fotId: e.target.value })}
disabled={!selectedInstitutionName}
>
<option value="">Selecione a Turma</option>
{filteredYears.map(f => (
<option key={f.id} value={f.id}>{f.label}</option>
))}
</select>
</div>
</>
)}
</div>
)}
</div>
<div className="flex flex-col sm:flex-row justify-end gap-3 sm:gap-0 mt-8">
<Button
onClick={() => setActiveTab("location")}
className="w-full sm:w-auto"
disabled={(!user?.empresaId && user?.role !== UserRole.SUPERADMIN)}
>
Próximo: Localização
</Button>
@ -647,8 +671,17 @@ export const EventForm: React.FC<EventFormProps> = ({
{activeTab === "location" && (
<div className="space-y-6 fade-in">
<div className="relative">
<label className="block text-sm font-medium text-gray-700 mb-1 tracking-wide uppercase text-xs">
{/* Nome do Local */}
<Input
label="Nome do Local"
placeholder="Ex: Espaço das Américas, Salão de Festas X"
value={formData.locationName}
onChange={(e) => setFormData({ ...formData, locationName: e.target.value })}
/>
<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">
Busca de Endereço (Powered by Mapbox)
</label>
<div className="relative">

View file

@ -132,7 +132,7 @@ export const EventTable: React.FC<EventTableProps> = ({
[EventStatus.CONFIRMED]: "Confirmado",
[EventStatus.PLANNING]: "Planejamento",
[EventStatus.IN_PROGRESS]: "Em Andamento",
[EventStatus.COMPLETED]: "Concluído",
[EventStatus.DELIVERED]: "Entregue",
[EventStatus.ARCHIVED]: "Arquivado",
};
return statusLabels[status] || status;
@ -232,7 +232,7 @@ export const EventTable: React.FC<EventTableProps> = ({
>
<td className="px-4 py-3">
<span className="text-sm font-medium text-gray-900">
{(event as any).fotId || "-"}
{event.fot || "-"}
</span>
</td>
<td className="px-4 py-3">
@ -242,22 +242,22 @@ export const EventTable: React.FC<EventTableProps> = ({
</td>
<td className="px-4 py-3">
<span className="text-sm text-gray-600">
{(event as any).curso || "-"}
{event.curso || "-"}
</span>
</td>
<td className="px-4 py-3">
<span className="text-sm text-gray-600">
{(event as any).instituicao || "-"}
{event.instituicao || "-"}
</span>
</td>
<td className="px-4 py-3">
<span className="text-sm text-gray-600">
{(event as any).anoFormatura || "-"}
{event.anoFormatura || "-"}
</span>
</td>
<td className="px-4 py-3">
<span className="text-sm text-gray-600">
{(event as any).empresa || "-"}
{event.empresa || "-"}
</span>
</td>
<td className="px-4 py-3">
@ -265,8 +265,7 @@ export const EventTable: React.FC<EventTableProps> = ({
</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
STATUS_COLORS[event.status] || "bg-gray-100 text-gray-800"
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${STATUS_COLORS[event.status] || "bg-gray-100 text-gray-800"
}`}
>
{getStatusDisplay(event.status)}

View file

@ -69,6 +69,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
name: backendUser.email.split('@')[0],
role: backendUser.role as UserRole,
ativo: backendUser.ativo,
empresaId: backendUser.company_id || backendUser.empresa_id || backendUser.companyId,
companyName: backendUser.company_name || backendUser.empresa_nome || backendUser.companyName,
};
if (!backendUser.ativo) {
console.warn("User is not active, logging out.");
@ -155,6 +157,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
name: backendUser.email.split('@')[0], // Fallback name or from profile if available
role: backendUser.role as UserRole,
ativo: backendUser.ativo,
empresaId: backendUser.company_id || backendUser.empresa_id || backendUser.companyId,
companyName: backendUser.company_name || backendUser.empresa_nome || backendUser.companyName,
// ... propagate other fields if needed or fetch profile
};
@ -227,6 +231,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
name: backendUser.nome || backendUser.email.split('@')[0],
role: backendUser.role as UserRole,
ativo: backendUser.ativo,
empresaId: backendUser.company_id || backendUser.empresa_id || backendUser.companyId,
companyName: backendUser.company_name || backendUser.empresa_nome || backendUser.companyName,
};
setUser(mappedUser);
}

View file

@ -1,4 +1,5 @@
import React, { createContext, useContext, useState, ReactNode, useEffect } from "react";
import { useAuth } from "./AuthContext";
import { getPendingUsers, approveUser as apiApproveUser } from "../services/apiService";
import {
EventData,
@ -607,15 +608,75 @@ interface DataContextType {
const DataContext = createContext<DataContextType | undefined>(undefined);
export const DataProvider: React.FC<{ children: ReactNode }> = ({
children,
}) => {
const { token, user } = useAuth(); // Consume Auth Context
const [events, setEvents] = useState<EventData[]>(INITIAL_EVENTS);
const [institutions, setInstitutions] =
useState<Institution[]>(INITIAL_INSTITUTIONS);
const [courses, setCourses] = useState<Course[]>(INITIAL_COURSES);
const [pendingUsers, setPendingUsers] = useState<User[]>([]);
// Fetch events from API
useEffect(() => {
const fetchEvents = async () => {
// 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");
if (visibleToken) {
try {
// Import dynamic to avoid circular dependency if any, or just use imported service
const { getAgendas } = await import("../services/apiService");
const result = await getAgendas(visibleToken);
if (result.data) {
const mappedEvents: EventData[] = result.data.map((e: any) => ({
id: e.id,
name: e.observacoes_evento || e.tipo_evento_nome || "Evento sem nome", // Fallback mapping
date: e.data_evento ? e.data_evento.split('T')[0] : "",
time: e.horario || "00:00",
type: (e.tipo_evento_nome || "Outro") as EventType, // Map string to enum if possible, or keep string
status: EventStatus.PENDING_APPROVAL, // Default or map from e.status_profissionais?? e.status_profissionais is for workers. We might need a status field on agenda table or infer it.
// For now, let's map status_profissionais to general status if possible, or default to CONFIRMED/PENDING
// e.status_profissionais defaults to "PENDING" in creation.
address: {
street: e.endereco ? e.endereco.split(',')[0] : "",
number: e.endereco ? e.endereco.split(',')[1]?.split('-')[0]?.trim() || "" : "",
city: e.endereco ? e.endereco.split('-')[1]?.split('/')[0]?.trim() || "" : "",
state: e.endereco ? e.endereco.split('/')[1]?.trim() || "" : "",
zip: "",
mapLink: e.local_evento.startsWith('http') ? e.local_evento : undefined
},
briefing: e.observacoes_evento || "",
coverImage: "https://picsum.photos/id/10/800/400", // Placeholder
contacts: [], // TODO: fetch contacts if needed
checklist: [],
ownerId: e.user_id || "unknown",
photographerIds: [], // TODO
institutionId: "", // TODO
attendees: e.qtd_formandos,
fotId: e.fot_id, // UUID
// Joined Fields
fot: e.fot_numero ?? e.fot_id, // Show Number if available (even 0), else ID
curso: e.curso_nome,
instituicao: e.instituicao,
anoFormatura: e.ano_semestre,
empresa: e.empresa_nome,
observacoes: e.observacoes_fot,
typeId: e.tipo_evento_id
}));
setEvents(mappedEvents);
}
} catch (error) {
console.error("Failed to fetch events", error);
}
}
};
fetchEvents();
}, [token]); // React to token change
// Fetch pending users from API
useEffect(() => {
const fetchUsers = async () => {
@ -653,8 +714,60 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({
fetchUsers();
}, []);
const addEvent = (event: EventData) => {
const addEvent = async (event: EventData) => {
const token = localStorage.getItem("@Photum:token");
if (!token) {
console.error("No token found");
// Fallback for offline/mock
setEvents((prev) => [event, ...prev]);
return;
}
try {
// Map frontend fields (camelCase) to backend fields (snake_case)
const payload = {
fot_id: event.fotId,
data_evento: event.date, // "YYYY-MM-DD" is acceptable
tipo_evento_id: event.typeId,
observacoes_evento: event.name, // "Observações do Evento" maps to name in EventForm
// local_evento: event.address.street + ", " + event.address.number, // Or map separate fields if needed
local_evento: event.address.mapLink || "Local a definir", // using mapLink or some string
endereco: `${event.address.street}, ${event.address.number}, ${event.address.city} - ${event.address.state}`,
horario: event.startTime,
// Defaulting missing counts to 0 for now as they are not in the simplified form
qtd_formandos: event.attendees ? parseInt(String(event.attendees)) : 0,
qtd_fotografos: 0,
qtd_recepcionistas: 0,
qtd_cinegrafistas: 0,
qtd_estudios: 0,
qtd_ponto_foto: 0,
qtd_ponto_id: 0,
qtd_ponto_decorado: 0,
qtd_pontos_led: 0,
qtd_plataforma_360: 0,
status_profissionais: "AGUARDANDO", // Will be calculated by backend anyway
foto_faltante: 0,
recep_faltante: 0,
cine_faltante: 0,
logistica_observacoes: "",
pre_venda: false
};
const result = await import("../services/apiService").then(m => m.createAgenda(token, payload));
if (result.data) {
// Success
console.log("Agenda criada:", result.data);
const newEvent = { ...event, id: result.data.id, status: EventStatus.PENDING_APPROVAL };
setEvents((prev) => [newEvent, ...prev]);
} else {
console.error("Erro ao criar agenda API:", result.error);
// Fallback or Toast?
// We will optimistically add it locally or throw
}
} catch (err) {
console.error("Exception creating agenda:", err);
}
};
const updateEventStatus = (id: string, status: EventStatus) => {

View file

@ -371,8 +371,7 @@ export const Dashboard: React.FC<DashboardProps> = ({
<div className="flex space-x-2 bg-white p-1 rounded border border-gray-200">
<button
onClick={() => setActiveFilter("all")}
className={`px-3 py-1 text-xs font-medium rounded-sm ${
activeFilter === "all"
className={`px-3 py-1 text-xs font-medium rounded-sm ${activeFilter === "all"
? "bg-brand-black text-white"
: "text-gray-600 hover:bg-gray-100"
}`}
@ -381,8 +380,7 @@ export const Dashboard: React.FC<DashboardProps> = ({
</button>
<button
onClick={() => setActiveFilter("pending")}
className={`px-3 py-1 text-xs font-medium rounded-sm flex items-center ${
activeFilter === "pending"
className={`px-3 py-1 text-xs font-medium rounded-sm flex items-center ${activeFilter === "pending"
? "bg-brand-gold text-white"
: "text-gray-600 hover:bg-gray-100"
}`}
@ -481,8 +479,7 @@ export const Dashboard: React.FC<DashboardProps> = ({
</div>
</div>
<div
className={`px-4 py-2 rounded text-sm font-semibold ${
STATUS_COLORS[selectedEvent.status]
className={`px-4 py-2 rounded text-sm font-semibold ${STATUS_COLORS[selectedEvent.status]
}`}
>
{selectedEvent.status}
@ -578,7 +575,7 @@ export const Dashboard: React.FC<DashboardProps> = ({
FOT
</td>
<td className="px-4 py-3 text-sm text-gray-900 font-medium">
{(selectedEvent as any).fotId || "-"}
{(selectedEvent as any).fot || "-"}
</td>
</tr>
<tr className="hover:bg-gray-50">
@ -596,7 +593,7 @@ export const Dashboard: React.FC<DashboardProps> = ({
Curso
</td>
<td className="px-4 py-3 text-sm text-gray-900">
{(selectedEvent as any).curso || "-"}
{selectedEvent.curso || "-"}
</td>
</tr>
<tr className="hover:bg-gray-50">
@ -604,7 +601,7 @@ export const Dashboard: React.FC<DashboardProps> = ({
Instituição
</td>
<td className="px-4 py-3 text-sm text-gray-900">
{(selectedEvent as any).instituicao || "-"}
{selectedEvent.instituicao || "-"}
</td>
</tr>
<tr className="hover:bg-gray-50">
@ -612,7 +609,7 @@ export const Dashboard: React.FC<DashboardProps> = ({
Ano Formatura
</td>
<td className="px-4 py-3 text-sm text-gray-900">
{(selectedEvent as any).anoFormatura || "-"}
{selectedEvent.anoFormatura || "-"}
</td>
</tr>
<tr className="hover:bg-gray-50">
@ -620,7 +617,7 @@ export const Dashboard: React.FC<DashboardProps> = ({
Empresa
</td>
<td className="px-4 py-3 text-sm text-gray-900">
{(selectedEvent as any).empresa || "-"}
{selectedEvent.empresa || "-"}
</td>
</tr>
<tr className="hover:bg-gray-50">
@ -672,7 +669,7 @@ export const Dashboard: React.FC<DashboardProps> = ({
Qtd Formandos
</td>
<td className="px-4 py-3 text-sm text-gray-900">
{(selectedEvent as any).qtdFormandos || "-"}
{selectedEvent.attendees || "-"}
</td>
</tr>
</tbody>
@ -866,8 +863,7 @@ export const Dashboard: React.FC<DashboardProps> = ({
<div
className="w-8 h-8 rounded-full border-2 border-gray-200 bg-gray-300 flex-shrink-0"
style={{
backgroundImage: `url(${
photographer?.avatar ||
backgroundImage: `url(${photographer?.avatar ||
`https://i.pravatar.cc/100?u=${id}`
})`,
backgroundSize: "cover",
@ -1034,8 +1030,7 @@ export const Dashboard: React.FC<DashboardProps> = ({
togglePhotographer(photographer.id)
}
disabled={!isAvailable && !isAssigned}
className={`px-4 py-2 rounded-lg font-medium text-sm transition-colors ${
isAssigned
className={`px-4 py-2 rounded-lg font-medium text-sm transition-colors ${isAssigned
? "bg-red-100 text-red-700 hover:bg-red-200"
: isAvailable
? "bg-brand-gold text-white hover:bg-[#a5bd2e]"

View file

@ -177,9 +177,17 @@ export async function getAvailableCourses(): Promise<ApiResponse<Array<{ id: str
/**
* Busca a listagem de Cadastro FOT
*/
export async function getCadastroFot(token: string): Promise<ApiResponse<any[]>> {
/**
* Busca a listagem de Cadastro FOT
*/
export async function getCadastroFot(token: string, empresaId?: string): Promise<ApiResponse<any[]>> {
try {
const response = await fetch(`${API_BASE_URL}/api/cadastro-fot`, {
let url = `${API_BASE_URL}/api/cadastro-fot`;
if (empresaId) {
url += `?empresa_id=${empresaId}`;
}
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
@ -270,7 +278,55 @@ export async function getUniversities(): Promise<
return fetchFromBackend("/api/universidades");
}
// ... existing functions ...
// Agenda
export const createAgenda = async (token: string, data: any) => {
try {
const response = await fetch(`${API_BASE_URL}/api/agenda`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
body: JSON.stringify(data),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
}
const responseData = await response.json();
return { data: responseData, error: null };
} catch (error: any) {
console.error("Erro ao criar agenda:", error);
return { data: null, error: error.message || "Erro ao criar agenda" };
}
};
// Agenda
export const getAgendas = async (token: string): Promise<ApiResponse<any[]>> => {
try {
const response = await fetch(`${API_BASE_URL}/api/agenda`, {
method: "GET",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return { data, error: null, isBackendDown: false };
} catch (error: any) {
console.error("Erro ao buscar agendas:", error);
return { data: null, error: error.message || "Erro ao buscar agendas", isBackendDown: true };
}
};
/**
* Busca usuários pendentes de aprovação

View file

@ -44,6 +44,8 @@ export interface User {
phone?: string; // Telefone do usuário
createdAt?: string; // Data de criação do cadastro
ativo?: boolean;
empresaId?: string; // ID da empresa vinculada (para Business Owners)
companyName?: string; // Nome da empresa vinculada
}
export interface Institution {
@ -115,4 +117,15 @@ export interface EventData {
institutionId?: string; // ID da instituição vinculada (obrigatório)
attendees?: number; // Número de pessoas participantes
courseId?: string; // ID do curso/turma relacionado ao evento
fotId?: string; // ID da Turma (FOT)
typeId?: string; // ID do Tipo de Evento (UUID)
// Fields populated from backend joins (ListAgendas)
fot?: string; // Nome/Número da Turma (FOT)
curso?: string; // Nome do Curso
instituicao?: string; // Nome da Instituição
anoFormatura?: string; // Ano/Semestre
empresa?: string; // Nome da Empresa
observacoes?: string; // Observações da FOT
tipoEventoNome?: string; // Nome do Tipo de Evento
}