diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index c167cc5..3924ce6 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -8,6 +8,7 @@ import ( "photum-backend/internal/agenda" "photum-backend/internal/anos_formaturas" "photum-backend/internal/auth" + "photum-backend/internal/availability" "photum-backend/internal/cadastro_fot" "photum-backend/internal/config" "photum-backend/internal/cursos" @@ -71,6 +72,7 @@ func main() { tiposEventosService := tipos_eventos.NewService(queries) cadastroFotService := cadastro_fot.NewService(queries) agendaService := agenda.NewService(queries) + availabilityService := availability.NewService(queries) s3Service := storage.NewS3Service(cfg) // Seed Demo Users @@ -89,6 +91,7 @@ func main() { tiposEventosHandler := tipos_eventos.NewHandler(tiposEventosService) cadastroFotHandler := cadastro_fot.NewHandler(cadastroFotService) agendaHandler := agenda.NewHandler(agendaService) + availabilityHandler := availability.NewHandler(availabilityService) r := gin.Default() @@ -197,8 +200,13 @@ func main() { api.DELETE("/agenda/:id/professionals/:profId", agendaHandler.RemoveProfessional) api.GET("/agenda/:id/professionals", agendaHandler.GetProfessionals) api.PATCH("/agenda/:id/professionals/:profId/status", agendaHandler.UpdateAssignmentStatus) + api.PATCH("/agenda/:id/professionals/:profId/position", agendaHandler.UpdateAssignmentPosition) + api.GET("/agenda/:id/available", agendaHandler.ListAvailableProfessionals) api.PATCH("/agenda/:id/status", agendaHandler.UpdateStatus) + api.POST("/availability", availabilityHandler.SetAvailability) + api.GET("/availability", availabilityHandler.ListAvailability) + admin := api.Group("/admin") { admin.GET("/users", authHandler.ListUsers) diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 4c46a7b..d794857 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -651,6 +651,15 @@ const docTemplate = `{ } } }, + "/api/agenda/{id}/available": { + "get": { + "tags": [ + "agenda" + ], + "summary": "List available professionals for agenda date", + "responses": {} + } + }, "/api/agenda/{id}/professionals": { "get": { "tags": [ @@ -676,6 +685,15 @@ const docTemplate = `{ "responses": {} } }, + "/api/agenda/{id}/professionals/{profId}/position": { + "patch": { + "tags": [ + "agenda" + ], + "summary": "Update professional position in agenda", + "responses": {} + } + }, "/api/agenda/{id}/professionals/{profId}/status": { "patch": { "tags": [ @@ -2490,6 +2508,79 @@ const docTemplate = `{ } } } + }, + "/availability": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "availability" + ], + "summary": "List my availability for a month with ?start=YYYY-MM-DD\u0026end=YYYY-MM-DD", + "parameters": [ + { + "type": "string", + "description": "Start Date", + "name": "start", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "End Date", + "name": "end", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "availability" + ], + "summary": "Set availability for a date", + "parameters": [ + { + "description": "Availability Input", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/availability.SetAvailabilityInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } } }, "definitions": { @@ -2712,6 +2803,19 @@ const docTemplate = `{ } } }, + "availability.SetAvailabilityInput": { + "type": "object", + "properties": { + "date": { + "description": "YYYY-MM-DD", + "type": "string" + }, + "status": { + "description": "DISPONIVEL, INDISPONIVEL", + "type": "string" + } + } + }, "cadastro_fot.CadastroFotResponse": { "type": "object", "properties": { diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 96ec642..636b7ea 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -645,6 +645,15 @@ } } }, + "/api/agenda/{id}/available": { + "get": { + "tags": [ + "agenda" + ], + "summary": "List available professionals for agenda date", + "responses": {} + } + }, "/api/agenda/{id}/professionals": { "get": { "tags": [ @@ -670,6 +679,15 @@ "responses": {} } }, + "/api/agenda/{id}/professionals/{profId}/position": { + "patch": { + "tags": [ + "agenda" + ], + "summary": "Update professional position in agenda", + "responses": {} + } + }, "/api/agenda/{id}/professionals/{profId}/status": { "patch": { "tags": [ @@ -2484,6 +2502,79 @@ } } } + }, + "/availability": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "availability" + ], + "summary": "List my availability for a month with ?start=YYYY-MM-DD\u0026end=YYYY-MM-DD", + "parameters": [ + { + "type": "string", + "description": "Start Date", + "name": "start", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "End Date", + "name": "end", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "availability" + ], + "summary": "Set availability for a date", + "parameters": [ + { + "description": "Availability Input", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/availability.SetAvailabilityInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } } }, "definitions": { @@ -2706,6 +2797,19 @@ } } }, + "availability.SetAvailabilityInput": { + "type": "object", + "properties": { + "date": { + "description": "YYYY-MM-DD", + "type": "string" + }, + "status": { + "description": "DISPONIVEL, INDISPONIVEL", + "type": "string" + } + } + }, "cadastro_fot.CadastroFotResponse": { "type": "object", "properties": { diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 5b0ec07..9993a91 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -147,6 +147,15 @@ definitions: role: type: string type: object + availability.SetAvailabilityInput: + properties: + date: + description: YYYY-MM-DD + type: string + status: + description: DISPONIVEL, INDISPONIVEL + type: string + type: object cadastro_fot.CadastroFotResponse: properties: ano_formatura_id: @@ -879,6 +888,12 @@ paths: summary: Update agenda event tags: - agenda + /api/agenda/{id}/available: + get: + responses: {} + summary: List available professionals for agenda date + tags: + - agenda /api/agenda/{id}/professionals: get: responses: {} @@ -896,6 +911,12 @@ paths: summary: Remove professional from agenda tags: - agenda + /api/agenda/{id}/professionals/{profId}/position: + patch: + responses: {} + summary: Update professional position in agenda + tags: + - agenda /api/agenda/{id}/professionals/{profId}/status: patch: responses: {} @@ -2038,6 +2059,54 @@ paths: summary: Get S3 Presigned URL for upload tags: - auth + /availability: + get: + parameters: + - description: Start Date + in: query + name: start + required: true + type: string + - description: End Date + in: query + name: end + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + additionalProperties: true + type: object + type: array + summary: List my availability for a month with ?start=YYYY-MM-DD&end=YYYY-MM-DD + tags: + - availability + post: + consumes: + - application/json + parameters: + - description: Availability Input + in: body + name: request + required: true + schema: + $ref: '#/definitions/availability.SetAvailabilityInput' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + summary: Set availability for a date + tags: + - availability securityDefinitions: BearerAuth: in: header diff --git a/backend/internal/agenda/handler.go b/backend/internal/agenda/handler.go index 51dca7f..1495f8b 100644 --- a/backend/internal/agenda/handler.go +++ b/backend/internal/agenda/handler.go @@ -308,3 +308,72 @@ func (h *Handler) UpdateAssignmentStatus(c *gin.Context) { c.Status(http.StatusOK) } + +// UpdateAssignmentPosition godoc +// @Summary Update professional position in agenda +// @Tags agenda +// @Router /api/agenda/{id}/professionals/{profId}/position [patch] +func (h *Handler) UpdateAssignmentPosition(c *gin.Context) { + idParam := c.Param("id") + agendaID, err := uuid.Parse(idParam) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "ID de agenda inválido"}) + return + } + + profIdParam := c.Param("profId") + profID, err := uuid.Parse(profIdParam) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "ID de profissional inválido"}) + return + } + + var req struct { + Posicao string `json:"posicao" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Dados inválidos: " + err.Error()}) + return + } + + if err := h.service.UpdateAssignmentPosition(c.Request.Context(), agendaID, profID, req.Posicao); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Erro ao atualizar posição: " + err.Error()}) + return + } + + c.Status(http.StatusOK) +} + +// ListAvailableProfessionals godoc +// @Summary List available professionals for agenda date +// @Tags agenda +// @Router /api/agenda/{id}/available [get] +func (h *Handler) ListAvailableProfessionals(c *gin.Context) { + idParam := c.Param("id") + agendaID, err := uuid.Parse(idParam) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "ID de agenda inválido"}) + return + } + + // Fetch agenda to get date + agenda, err := h.service.Get(c.Request.Context(), agendaID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Erro ao buscar agenda: " + err.Error()}) + return + } + + // Check if date is valid + if !agenda.DataEvento.Valid { + c.JSON(http.StatusBadRequest, gin.H{"error": "Agenda sem data definida"}) + return + } + + profs, err := h.service.ListAvailableProfessionals(c.Request.Context(), agenda.DataEvento.Time) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Erro ao buscar profissionais disponíveis: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, profs) +} diff --git a/backend/internal/agenda/service.go b/backend/internal/agenda/service.go index ca691df..de78f95 100644 --- a/backend/internal/agenda/service.go +++ b/backend/internal/agenda/service.go @@ -264,3 +264,17 @@ func (s *Service) UpdateAssignmentStatus(ctx context.Context, agendaID, professi _, err := s.queries.UpdateAssignmentStatus(ctx, params) return err } + +func (s *Service) UpdateAssignmentPosition(ctx context.Context, agendaID, professionalID uuid.UUID, posicao string) error { + params := generated.UpdateAssignmentPositionParams{ + AgendaID: pgtype.UUID{Bytes: agendaID, Valid: true}, + ProfissionalID: pgtype.UUID{Bytes: professionalID, Valid: true}, + Posicao: pgtype.Text{String: posicao, Valid: true}, + } + _, err := s.queries.UpdateAssignmentPosition(ctx, params) + return err +} + +func (s *Service) ListAvailableProfessionals(ctx context.Context, date time.Time) ([]generated.ListAvailableProfessionalsForDateRow, error) { + return s.queries.ListAvailableProfessionalsForDate(ctx, pgtype.Date{Time: date, Valid: true}) +} diff --git a/backend/internal/availability/handler.go b/backend/internal/availability/handler.go new file mode 100644 index 0000000..097d1d0 --- /dev/null +++ b/backend/internal/availability/handler.go @@ -0,0 +1,83 @@ +package availability + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + service *Service +} + +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + +// SetAvailability godoc +// @Summary Set availability for a date +// @Tags availability +// @Accept json +// @Produce json +// @Param request body SetAvailabilityInput true "Availability Input" +// @Success 200 {object} map[string]string +// @Router /availability [post] +func (h *Handler) SetAvailability(c *gin.Context) { + var req SetAvailabilityInput + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + userID := c.GetString("userID") // From middleware + if userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + + if err := h.service.SetAvailability(c.Request.Context(), userID, req); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "availability updated"}) +} + +// ListAvailability godoc +// @Summary List my availability for a month with ?start=YYYY-MM-DD&end=YYYY-MM-DD +// @Tags availability +// @Produce json +// @Param start query string true "Start Date" +// @Param end query string true "End Date" +// @Success 200 {array} map[string]interface{} +// @Router /availability [get] +func (h *Handler) ListAvailability(c *gin.Context) { + userId := c.GetString("userID") + start := c.Query("start") + end := c.Query("end") + + if start == "" || end == "" { + // Default to current month + now := time.Now() + start = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC).Format("2006-01-02") + end = time.Date(now.Year(), now.Month()+1, 0, 0, 0, 0, 0, time.UTC).Format("2006-01-02") + } + + availabilities, err := h.service.ListMyAvailability(c.Request.Context(), userId, start, end) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Map to simplify response + var response []map[string]interface{} + for _, a := range availabilities { + response = append(response, map[string]interface{}{ + "date": a.Data.Time.Format("2006-01-02"), + "status": a.Status, + }) + } + + c.JSON(http.StatusOK, response) +} diff --git a/backend/internal/availability/service.go b/backend/internal/availability/service.go new file mode 100644 index 0000000..3c9f56d --- /dev/null +++ b/backend/internal/availability/service.go @@ -0,0 +1,75 @@ +package availability + +import ( + "context" + "time" + + "photum-backend/internal/db/generated" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +type Service struct { + queries *generated.Queries +} + +func NewService(queries *generated.Queries) *Service { + return &Service{queries: queries} +} + +type SetAvailabilityInput struct { + Date string `json:"date"` // YYYY-MM-DD + Status string `json:"status"` // DISPONIVEL, INDISPONIVEL +} + +func (s *Service) SetAvailability(ctx context.Context, userID string, input SetAvailabilityInput) error { + userUUID, err := uuid.Parse(userID) + if err != nil { + return err + } + + date, err := time.Parse("2006-01-02", input.Date) + if err != nil { + return err + } + + pgDate := pgtype.Date{Time: date, Valid: true} + + if input.Status == "INDISPONIVEL" { + // Remove record if unavailable (or explicit status if we prefer history, but opt-in usually means record exists = available) + // Specifically for this implementation, let's follow the schema which has a status column. + // However, "availability" usually implies "I am available". + // If I set "INDISPONIVEL", I can either delete the record or set status to INDISPONIVEL. + // Let's set status. + } + + _, err = s.queries.CreateDisponibilidade(ctx, generated.CreateDisponibilidadeParams{ + UsuarioID: pgtype.UUID{Bytes: userUUID, Valid: true}, + Data: pgDate, + Status: input.Status, + }) + return err +} + +func (s *Service) ListMyAvailability(ctx context.Context, userID string, startDate, endDate string) ([]generated.DisponibilidadeProfissionai, error) { + userUUID, err := uuid.Parse(userID) + if err != nil { + return nil, err + } + + start, err := time.Parse("2006-01-02", startDate) + if err != nil { + return nil, err + } + end, err := time.Parse("2006-01-02", endDate) + if err != nil { + return nil, err + } + + return s.queries.ListDisponibilidadeByPeriod(ctx, generated.ListDisponibilidadeByPeriodParams{ + UsuarioID: pgtype.UUID{Bytes: userUUID, Valid: true}, + Data: pgtype.Date{Time: start, Valid: true}, + Data_2: pgtype.Date{Time: end, Valid: true}, + }) +} diff --git a/backend/internal/db/generated/agenda.sql.go b/backend/internal/db/generated/agenda.sql.go index 806ad00..bc0410f 100644 --- a/backend/internal/db/generated/agenda.sql.go +++ b/backend/internal/db/generated/agenda.sql.go @@ -541,6 +541,114 @@ func (q *Queries) ListAgendasByUser(ctx context.Context, userID pgtype.UUID) ([] return items, nil } +const listAvailableProfessionalsForDate = `-- name: ListAvailableProfessionalsForDate :many +SELECT + p.id, p.usuario_id, p.nome, p.funcao_profissional_id, p.endereco, p.cidade, p.uf, p.whatsapp, p.cpf_cnpj_titular, p.banco, p.agencia, 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.avatar_url, p.criado_em, p.atualizado_em, + u.email, + f.nome as funcao_nome, + dp.status as status_disponibilidade +FROM cadastro_profissionais p +JOIN usuarios u ON p.usuario_id = u.id +JOIN funcoes_profissionais f ON p.funcao_profissional_id = f.id +JOIN disponibilidade_profissionais dp ON u.id = dp.usuario_id +WHERE dp.data = $1 + AND dp.status = 'DISPONIVEL' + AND p.id NOT IN ( + SELECT ap.profissional_id + FROM agenda_profissionais ap + JOIN agenda a ON ap.agenda_id = a.id + WHERE a.data_evento = $1 + AND ap.status = 'ACEITO' + ) +ORDER BY p.nome +` + +type ListAvailableProfessionalsForDateRow 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"` + CpfCnpjTitular pgtype.Text `json:"cpf_cnpj_titular"` + Banco pgtype.Text `json:"banco"` + Agencia pgtype.Text `json:"agencia"` + 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"` + AvatarUrl pgtype.Text `json:"avatar_url"` + CriadoEm pgtype.Timestamptz `json:"criado_em"` + AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"` + Email string `json:"email"` + FuncaoNome string `json:"funcao_nome"` + StatusDisponibilidade string `json:"status_disponibilidade"` +} + +func (q *Queries) ListAvailableProfessionalsForDate(ctx context.Context, data pgtype.Date) ([]ListAvailableProfessionalsForDateRow, error) { + rows, err := q.db.Query(ctx, listAvailableProfessionalsForDate, data) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListAvailableProfessionalsForDateRow + for rows.Next() { + var i ListAvailableProfessionalsForDateRow + if err := rows.Scan( + &i.ID, + &i.UsuarioID, + &i.Nome, + &i.FuncaoProfissionalID, + &i.Endereco, + &i.Cidade, + &i.Uf, + &i.Whatsapp, + &i.CpfCnpjTitular, + &i.Banco, + &i.Agencia, + &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.AvatarUrl, + &i.CriadoEm, + &i.AtualizadoEm, + &i.Email, + &i.FuncaoNome, + &i.StatusDisponibilidade, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const removeProfessional = `-- name: RemoveProfessional :exec DELETE FROM agenda_profissionais WHERE agenda_id = $1 AND profissional_id = $2 @@ -723,11 +831,39 @@ func (q *Queries) UpdateAgendaStatus(ctx context.Context, arg UpdateAgendaStatus return i, err } +const updateAssignmentPosition = `-- name: UpdateAssignmentPosition :one +UPDATE agenda_profissionais +SET posicao = $3 +WHERE agenda_id = $1 AND profissional_id = $2 +RETURNING id, agenda_id, profissional_id, status, motivo_rejeicao, criado_em, posicao +` + +type UpdateAssignmentPositionParams struct { + AgendaID pgtype.UUID `json:"agenda_id"` + ProfissionalID pgtype.UUID `json:"profissional_id"` + Posicao pgtype.Text `json:"posicao"` +} + +func (q *Queries) UpdateAssignmentPosition(ctx context.Context, arg UpdateAssignmentPositionParams) (AgendaProfissionai, error) { + row := q.db.QueryRow(ctx, updateAssignmentPosition, arg.AgendaID, arg.ProfissionalID, arg.Posicao) + var i AgendaProfissionai + err := row.Scan( + &i.ID, + &i.AgendaID, + &i.ProfissionalID, + &i.Status, + &i.MotivoRejeicao, + &i.CriadoEm, + &i.Posicao, + ) + return i, err +} + const updateAssignmentStatus = `-- name: UpdateAssignmentStatus :one UPDATE agenda_profissionais SET status = $3, motivo_rejeicao = $4 WHERE agenda_id = $1 AND profissional_id = $2 -RETURNING id, agenda_id, profissional_id, status, motivo_rejeicao, criado_em +RETURNING id, agenda_id, profissional_id, status, motivo_rejeicao, criado_em, posicao ` type UpdateAssignmentStatusParams struct { @@ -752,6 +888,7 @@ func (q *Queries) UpdateAssignmentStatus(ctx context.Context, arg UpdateAssignme &i.Status, &i.MotivoRejeicao, &i.CriadoEm, + &i.Posicao, ) return i, err } diff --git a/backend/internal/db/generated/availability.sql.go b/backend/internal/db/generated/availability.sql.go new file mode 100644 index 0000000..5271c1e --- /dev/null +++ b/backend/internal/db/generated/availability.sql.go @@ -0,0 +1,117 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: availability.sql + +package generated + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createDisponibilidade = `-- name: CreateDisponibilidade :one +INSERT INTO disponibilidade_profissionais (usuario_id, data, status) +VALUES ($1, $2, $3) +ON CONFLICT (usuario_id, data) DO UPDATE +SET status = EXCLUDED.status, criado_em = NOW() +RETURNING id, usuario_id, data, status, criado_em +` + +type CreateDisponibilidadeParams struct { + UsuarioID pgtype.UUID `json:"usuario_id"` + Data pgtype.Date `json:"data"` + Status string `json:"status"` +} + +func (q *Queries) CreateDisponibilidade(ctx context.Context, arg CreateDisponibilidadeParams) (DisponibilidadeProfissionai, error) { + row := q.db.QueryRow(ctx, createDisponibilidade, arg.UsuarioID, arg.Data, arg.Status) + var i DisponibilidadeProfissionai + err := row.Scan( + &i.ID, + &i.UsuarioID, + &i.Data, + &i.Status, + &i.CriadoEm, + ) + return i, err +} + +const deleteDisponibilidade = `-- name: DeleteDisponibilidade :exec +DELETE FROM disponibilidade_profissionais +WHERE usuario_id = $1 AND data = $2 +` + +type DeleteDisponibilidadeParams struct { + UsuarioID pgtype.UUID `json:"usuario_id"` + Data pgtype.Date `json:"data"` +} + +func (q *Queries) DeleteDisponibilidade(ctx context.Context, arg DeleteDisponibilidadeParams) error { + _, err := q.db.Exec(ctx, deleteDisponibilidade, arg.UsuarioID, arg.Data) + return err +} + +const getDisponibilidadeByDate = `-- name: GetDisponibilidadeByDate :one +SELECT id, usuario_id, data, status, criado_em FROM disponibilidade_profissionais +WHERE usuario_id = $1 AND data = $2 +` + +type GetDisponibilidadeByDateParams struct { + UsuarioID pgtype.UUID `json:"usuario_id"` + Data pgtype.Date `json:"data"` +} + +func (q *Queries) GetDisponibilidadeByDate(ctx context.Context, arg GetDisponibilidadeByDateParams) (DisponibilidadeProfissionai, error) { + row := q.db.QueryRow(ctx, getDisponibilidadeByDate, arg.UsuarioID, arg.Data) + var i DisponibilidadeProfissionai + err := row.Scan( + &i.ID, + &i.UsuarioID, + &i.Data, + &i.Status, + &i.CriadoEm, + ) + return i, err +} + +const listDisponibilidadeByPeriod = `-- name: ListDisponibilidadeByPeriod :many +SELECT id, usuario_id, data, status, criado_em FROM disponibilidade_profissionais +WHERE usuario_id = $1 + AND data >= $2 + AND data <= $3 +ORDER BY data +` + +type ListDisponibilidadeByPeriodParams struct { + UsuarioID pgtype.UUID `json:"usuario_id"` + Data pgtype.Date `json:"data"` + Data_2 pgtype.Date `json:"data_2"` +} + +func (q *Queries) ListDisponibilidadeByPeriod(ctx context.Context, arg ListDisponibilidadeByPeriodParams) ([]DisponibilidadeProfissionai, error) { + rows, err := q.db.Query(ctx, listDisponibilidadeByPeriod, arg.UsuarioID, arg.Data, arg.Data_2) + if err != nil { + return nil, err + } + defer rows.Close() + var items []DisponibilidadeProfissionai + for rows.Next() { + var i DisponibilidadeProfissionai + if err := rows.Scan( + &i.ID, + &i.UsuarioID, + &i.Data, + &i.Status, + &i.CriadoEm, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/backend/internal/db/generated/models.go b/backend/internal/db/generated/models.go index f226024..e88cbcf 100644 --- a/backend/internal/db/generated/models.go +++ b/backend/internal/db/generated/models.go @@ -46,6 +46,7 @@ type AgendaProfissionai struct { Status pgtype.Text `json:"status"` MotivoRejeicao pgtype.Text `json:"motivo_rejeicao"` CriadoEm pgtype.Timestamptz `json:"criado_em"` + Posicao pgtype.Text `json:"posicao"` } type AnosFormatura struct { @@ -117,6 +118,14 @@ type Curso struct { CriadoEm pgtype.Timestamptz `json:"criado_em"` } +type DisponibilidadeProfissionai struct { + ID pgtype.UUID `json:"id"` + UsuarioID pgtype.UUID `json:"usuario_id"` + Data pgtype.Date `json:"data"` + Status string `json:"status"` + CriadoEm pgtype.Timestamptz `json:"criado_em"` +} + type Empresa struct { ID pgtype.UUID `json:"id"` Nome string `json:"nome"` diff --git a/backend/internal/db/queries/agenda.sql b/backend/internal/db/queries/agenda.sql index e2a525e..fc1d269 100644 --- a/backend/internal/db/queries/agenda.sql +++ b/backend/internal/db/queries/agenda.sql @@ -151,3 +151,30 @@ UPDATE agenda_profissionais SET status = $3, motivo_rejeicao = $4 WHERE agenda_id = $1 AND profissional_id = $2 RETURNING *; + +-- name: UpdateAssignmentPosition :one +UPDATE agenda_profissionais +SET posicao = $3 +WHERE agenda_id = $1 AND profissional_id = $2 +RETURNING *; + +-- name: ListAvailableProfessionalsForDate :many +SELECT + p.*, + u.email, + f.nome as funcao_nome, + dp.status as status_disponibilidade +FROM cadastro_profissionais p +JOIN usuarios u ON p.usuario_id = u.id +JOIN funcoes_profissionais f ON p.funcao_profissional_id = f.id +JOIN disponibilidade_profissionais dp ON u.id = dp.usuario_id +WHERE dp.data = $1 + AND dp.status = 'DISPONIVEL' + AND p.id NOT IN ( + SELECT ap.profissional_id + FROM agenda_profissionais ap + JOIN agenda a ON ap.agenda_id = a.id + WHERE a.data_evento = $1 + AND ap.status = 'ACEITO' + ) +ORDER BY p.nome; diff --git a/backend/internal/db/queries/availability.sql b/backend/internal/db/queries/availability.sql new file mode 100644 index 0000000..5abb809 --- /dev/null +++ b/backend/internal/db/queries/availability.sql @@ -0,0 +1,21 @@ +-- name: CreateDisponibilidade :one +INSERT INTO disponibilidade_profissionais (usuario_id, data, status) +VALUES ($1, $2, $3) +ON CONFLICT (usuario_id, data) DO UPDATE +SET status = EXCLUDED.status, criado_em = NOW() +RETURNING *; + +-- name: ListDisponibilidadeByPeriod :many +SELECT * FROM disponibilidade_profissionais +WHERE usuario_id = $1 + AND data >= $2 + AND data <= $3 +ORDER BY data; + +-- name: GetDisponibilidadeByDate :one +SELECT * FROM disponibilidade_profissionais +WHERE usuario_id = $1 AND data = $2; + +-- name: DeleteDisponibilidade :exec +DELETE FROM disponibilidade_profissionais +WHERE usuario_id = $1 AND data = $2; diff --git a/backend/internal/db/schema.sql b/backend/internal/db/schema.sql index 71fd6d9..747ef01 100644 --- a/backend/internal/db/schema.sql +++ b/backend/internal/db/schema.sql @@ -356,3 +356,14 @@ CREATE TABLE IF NOT EXISTS agenda_profissionais ( criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(agenda_id, profissional_id) ); + +ALTER TABLE agenda_profissionais ADD COLUMN IF NOT EXISTS posicao VARCHAR(100); + +CREATE TABLE IF NOT EXISTS disponibilidade_profissionais ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + usuario_id UUID NOT NULL REFERENCES usuarios(id) ON DELETE CASCADE, + data DATE NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'DISPONIVEL', -- DISPONIVEL, INDISPONIVEL + criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(usuario_id, data) +); diff --git a/frontend/components/ProfessionalDetailsModal.tsx b/frontend/components/ProfessionalDetailsModal.tsx new file mode 100644 index 0000000..22c73db --- /dev/null +++ b/frontend/components/ProfessionalDetailsModal.tsx @@ -0,0 +1,123 @@ +import React from 'react'; +import { Professional } from '../types'; +import { Button } from './Button'; +import { X, Mail, Phone, MapPin, Building, Star, Camera, DollarSign, Award } from 'lucide-react'; + +interface ProfessionalDetailsModalProps { + professional: Professional; + isOpen: boolean; + onClose: () => void; +} + +export const ProfessionalDetailsModal: React.FC = ({ + professional, + isOpen, + onClose, +}) => { + if (!isOpen) return null; + + return ( +
+
+ + {/* Header com Capa/Avatar Style */} +
+ +
+ + {/* Conteúdo Principal */} +
+ + {/* Avatar Grande */} +
+ +
+

{professional.name}

+
+ + {professional.role} + + {/* Mock de Avaliação */} + + 4.9 + +
+
+ +
+ +
+

+ + Dados Pessoais +

+ +
+
+ + {professional.email} +
+
+ + {professional.phone || "Não informado"} +
+ {/* Endereço Mockado se não tiver no tipo, ou usar campos extras do backend se mapeados */} +
+ + São Paulo, SP +
+
+
+ +
+

+ + Equipamentos & Habilidades +

+ + {/* Mock de Habilidades / Equipamentos (pois não está no type Professional simples ainda) */} +
+

Equipamento Profissional: Canon R6, Lentes série L

+
+ {["Formatura", "Casamento", "Estúdio"].map(tag => ( + {tag} + ))} +
+
+
+ +
+ +
+
+ +
+

Performance

+

Este profissional tem mantido uma taxa de 100% de presença e alta satisfação nos últimos eventos.

+
+
+
+ +
+ +
+ +
+
+
+ ); +}; diff --git a/frontend/contexts/DataContext.tsx b/frontend/contexts/DataContext.tsx index b1cd613..b3f718e 100644 --- a/frontend/contexts/DataContext.tsx +++ b/frontend/contexts/DataContext.tsx @@ -881,12 +881,22 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({ prev.map((e) => { if (e.id === eventId) { const current = e.photographerIds || []; + const currentAssignments = e.assignments || []; if (current.includes(photographerId)) { // Remove - return { ...e, photographerIds: current.filter(id => id !== photographerId) }; + return { + ...e, + photographerIds: current.filter(id => id !== photographerId), + assignments: currentAssignments.filter(a => a.professionalId !== photographerId) + }; } else { // Add - return { ...e, photographerIds: [...current, photographerId] }; + // Import AssignmentStatus if needed or use string "PENDENTE" matching the type + return { + ...e, + photographerIds: [...current, photographerId], + assignments: [...currentAssignments, { professionalId: photographerId, status: "PENDENTE" as any }] + }; } } return e; diff --git a/frontend/pages/Dashboard.tsx b/frontend/pages/Dashboard.tsx index 4222b07..dea6e35 100644 --- a/frontend/pages/Dashboard.tsx +++ b/frontend/pages/Dashboard.tsx @@ -22,13 +22,12 @@ import { import { useAuth } from "../contexts/AuthContext"; import { useData } from "../contexts/DataContext"; import { STATUS_COLORS } from "../constants"; +import { ProfessionalDetailsModal } from "../components/ProfessionalDetailsModal"; interface DashboardProps { initialView?: "list" | "create"; } - - export const Dashboard: React.FC = ({ initialView = "list", }) => { @@ -56,8 +55,8 @@ export const Dashboard: React.FC = ({ type: "", }); const [isTeamModalOpen, setIsTeamModalOpen] = useState(false); + const [viewingProfessional, setViewingProfessional] = useState(null); - // Reset view when initialView prop changes useEffect(() => { if (initialView) { setView(initialView); @@ -65,6 +64,10 @@ export const Dashboard: React.FC = ({ } }, [initialView]); + const handleViewProfessional = (professional: Professional) => { + setViewingProfessional(professional); + }; + // Guard Clause for basic security if (!user) return
Acesso Negado. Faça login.
; @@ -730,61 +733,76 @@ export const Dashboard: React.FC = ({
{/* Equipe Designada */} - {(selectedEvent.photographerIds.length > 0 || - user.role === UserRole.BUSINESS_OWNER || - user.role === UserRole.SUPERADMIN) && ( -
-
-

- - Equipe ({selectedEvent.photographerIds.length}) -

- {(user.role === UserRole.BUSINESS_OWNER || - user.role === UserRole.SUPERADMIN) && ( - - )} -
+ {(selectedEvent.assignments && selectedEvent.assignments.filter(a => a.status !== "REJEITADO").length > 0) || + ((user.role === UserRole.BUSINESS_OWNER || user.role === UserRole.SUPERADMIN) && selectedEvent.photographerIds.length > 0) ? ( +
+
+

+ + Equipe ({(selectedEvent.assignments || []).filter(a => a.status !== "REJEITADO").length}) +

+ {(user.role === UserRole.BUSINESS_OWNER || + user.role === UserRole.SUPERADMIN) && ( + + )} +
- {selectedEvent.photographerIds.length > 0 ? ( -
- {selectedEvent.photographerIds.map((id) => { - const photographer = professionals.find( - (p) => p.id === id - ); - return ( + {(selectedEvent.assignments || []) + .filter(a => a.status !== "REJEITADO") + .map((assignment) => { + const photographer = professionals.find( + (p) => p.id === assignment.professionalId + ); + return ( +
+
handleViewProfessional(photographer!)} + >
-
- - {photographer?.name || id} + className="w-8 h-8 rounded-full border-2 border-gray-200 bg-gray-300 flex-shrink-0" + style={{ + backgroundImage: `url(${photographer?.avatar || + `https://i.pravatar.cc/100?u=${assignment.professionalId}` + })`, + backgroundSize: "cover", + }} + >
+
+ + {photographer?.name || "Fotógrafo"} + + + {assignment.status === "PENDENTE" ? "Convite Pendente" : "Confirmado"}
- ); - })} -
- ) : ( -

- Nenhum profissional atribuído -

- )} -
- )} +
+ {assignment.status === "PENDENTE" && ( + + )} + {assignment.status === "ACEITO" && ( + + )} +
+ ); + })} + {(selectedEvent.assignments || []).filter(a => a.status !== "REJEITADO").length === 0 && ( +

+ Nenhum profissional na equipe. +

+ )} +
+ ) : null + } @@ -855,10 +873,12 @@ export const Dashboard: React.FC = ({ {professionals.map((photographer) => { - const isAssigned = - selectedEvent.photographerIds.includes( - photographer.id - ); + const assignment = (selectedEvent.assignments || []).find( + (a) => a.professionalId === photographer.id + ); + + const status = assignment ? assignment.status : null; + const isAssigned = !!status && status !== "REJEITADO"; // Consider assigned if not rejected (effectively) const isAvailable = true; return ( @@ -867,7 +887,7 @@ export const Dashboard: React.FC = ({ className="border-b border-gray-100 hover:bg-gray-50 transition-colors" > {/* Profissional */} - + handleViewProfessional(photographer)}>
= ({ {/* Status */} - {isAssigned ? ( - - - Atribuído - - ) : ( + {status === "ACEITO" && ( + + Confirmado + + )} + {status === "PENDENTE" && ( + + + Pendente + + )} + {status === "REJEITADO" && ( + + + Recusado + + )} + {!status && ( + Disponível @@ -920,15 +953,13 @@ export const Dashboard: React.FC = ({ onClick={() => togglePhotographer(photographer.id) } - disabled={!isAvailable && !isAssigned} - className={`px-4 py-2 rounded-lg font-medium text-sm transition-colors ${isAssigned + disabled={false} + className={`px-4 py-2 rounded-lg font-medium text-sm transition-colors ${status === "ACEITO" || status === "PENDENTE" ? "bg-red-100 text-red-700 hover:bg-red-200" - : isAvailable - ? "bg-brand-gold text-white hover:bg-[#a5bd2e]" - : "bg-gray-100 text-gray-400 cursor-not-allowed" + : "bg-brand-gold text-white hover:bg-[#a5bd2e]" }`} > - {isAssigned ? "Remover" : "Adicionar"} + {status === "ACEITO" || status === "PENDENTE" ? "Remover" : "Adicionar"} @@ -967,6 +998,14 @@ export const Dashboard: React.FC = ({
)} + + {viewingProfessional && ( + setViewingProfessional(null)} + /> + )} );