feat(staffing): implementa sistema de disponibilidade e escalonamento

- Adiciona tabela `disponibilidade_profissionais` no schema
- Adiciona coluna `posicao` para mapa de profissionais no evento
- Implementa Service e Handler para gerenciar Disponibilidade (Set/Get)
- Implementa lógica de Escalonamento: listar disponíveis e atribuir posição
- Atualiza rotas da API e serviço do frontend

feat(frontend): implementa modal de detalhes e updates otimistas na equipe

- Adiciona componente ProfessionalDetailsModal para exibir dados completos do profissional
- Implementa update otimista em DataContext para adição/removação instantânea da equipe
- Corrige bug crítico de sintaxe e estrutura na Dashboard.tsx
- Adiciona badges de status (Pendente/Confirmado/Rejeitado) na listagem de equipe
- Corrige erro de referência de variável (professionalId) no fluxo de atribuição
This commit is contained in:
NANDO9322 2025-12-23 17:29:40 -03:00
parent cd196a0275
commit 434548c158
17 changed files with 1094 additions and 74 deletions

View file

@ -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)

View file

@ -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": {

View file

@ -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": {

View file

@ -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

View file

@ -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)
}

View file

@ -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})
}

View file

@ -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)
}

View file

@ -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},
})
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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"`

View file

@ -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;

View file

@ -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;

View file

@ -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)
);

View file

@ -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<ProfessionalDetailsModalProps> = ({
professional,
isOpen,
onClose,
}) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 animate-fadeIn">
<div className="bg-white rounded-2xl shadow-2xl max-w-2xl w-full overflow-hidden flex flex-col relative animate-slideIn">
{/* Header com Capa/Avatar Style */}
<div className="h-32 bg-gradient-to-r from-brand-purple to-brand-purple/80 relative">
<button
onClick={onClose}
className="absolute top-4 right-4 text-white hover:bg-white/20 p-2 rounded-full transition-colors"
>
<X size={24} />
</button>
</div>
{/* Conteúdo Principal */}
<div className="px-8 pb-8 -mt-16 flex flex-col items-center sm:items-start relative z-10">
{/* Avatar Grande */}
<div
className="w-32 h-32 rounded-full border-4 border-white bg-white shadow-lg mb-4"
style={{
backgroundImage: `url(${professional.avatar || `https://ui-avatars.com/api/?name=${encodeURIComponent(professional.name)}&background=random`})`,
backgroundSize: 'cover',
backgroundPosition: 'center'
}}
/>
<div className="text-center sm:text-left w-full">
<h2 className="text-2xl font-serif font-bold text-brand-black">{professional.name}</h2>
<div className="flex flex-wrap justify-center sm:justify-start gap-2 mt-2">
<span className="px-3 py-1 bg-brand-gold/10 text-brand-black rounded-full text-sm font-medium border border-brand-gold/20">
{professional.role}
</span>
{/* Mock de Avaliação */}
<span className="px-3 py-1 bg-yellow-50 text-yellow-700 rounded-full text-sm font-medium border border-yellow-200 flex items-center gap-1">
<Star size={14} className="fill-yellow-500 text-yellow-500" /> 4.9
</span>
</div>
</div>
<div className="w-full grid grid-cols-1 md:grid-cols-2 gap-6 mt-8">
<div className="space-y-4">
<h3 className="font-bold text-gray-900 border-b pb-2 flex items-center gap-2">
<Building size={18} className="text-brand-gold" />
Dados Pessoais
</h3>
<div className="space-y-3 text-sm">
<div className="flex items-center gap-3 text-gray-700">
<Mail size={16} className="text-gray-400" />
<span>{professional.email}</span>
</div>
<div className="flex items-center gap-3 text-gray-700">
<Phone size={16} className="text-gray-400" />
<span>{professional.phone || "Não informado"}</span>
</div>
{/* Endereço Mockado se não tiver no tipo, ou usar campos extras do backend se mapeados */}
<div className="flex items-center gap-3 text-gray-700">
<MapPin size={16} className="text-gray-400" />
<span>São Paulo, SP</span>
</div>
</div>
</div>
<div className="space-y-4">
<h3 className="font-bold text-gray-900 border-b pb-2 flex items-center gap-2">
<Camera size={18} className="text-brand-gold" />
Equipamentos & Habilidades
</h3>
{/* Mock de Habilidades / Equipamentos (pois não está no type Professional simples ainda) */}
<div className="text-sm text-gray-600 space-y-2">
<p>Equipamento Profissional: <span className="text-gray-900">Canon R6, Lentes série L</span></p>
<div className="flex flex-wrap gap-2 mt-2">
{["Formatura", "Casamento", "Estúdio"].map(tag => (
<span key={tag} className="text-xs bg-gray-100 px-2 py-1 rounded text-gray-600">{tag}</span>
))}
</div>
</div>
</div>
</div>
<div className="w-full mt-8 bg-gray-50 p-4 rounded-lg border border-gray-100">
<div className="flex items-start gap-3">
<Award className="text-brand-gold mt-1" size={20} />
<div>
<h4 className="font-bold text-gray-900 text-sm">Performance</h4>
<p className="text-sm text-gray-600 mt-1">Este profissional tem mantido uma taxa de 100% de presença e alta satisfação nos últimos eventos.</p>
</div>
</div>
</div>
<div className="w-full mt-8 flex justify-end">
<Button variant="outline" onClick={onClose}>
Fechar
</Button>
</div>
</div>
</div>
</div>
);
};

View file

@ -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;

View file

@ -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<DashboardProps> = ({
initialView = "list",
}) => {
@ -56,8 +55,8 @@ export const Dashboard: React.FC<DashboardProps> = ({
type: "",
});
const [isTeamModalOpen, setIsTeamModalOpen] = useState(false);
const [viewingProfessional, setViewingProfessional] = useState<Professional | null>(null);
// Reset view when initialView prop changes
useEffect(() => {
if (initialView) {
setView(initialView);
@ -65,6 +64,10 @@ export const Dashboard: React.FC<DashboardProps> = ({
}
}, [initialView]);
const handleViewProfessional = (professional: Professional) => {
setViewingProfessional(professional);
};
// Guard Clause for basic security
if (!user)
return <div className="p-10 text-center">Acesso Negado. Faça login.</div>;
@ -730,61 +733,76 @@ export const Dashboard: React.FC<DashboardProps> = ({
</div>
{/* Equipe Designada */}
{(selectedEvent.photographerIds.length > 0 ||
user.role === UserRole.BUSINESS_OWNER ||
user.role === UserRole.SUPERADMIN) && (
<div className="border p-5 rounded bg-white">
<div className="flex justify-between items-center mb-3">
<h4 className="font-bold text-sm text-gray-700 flex items-center gap-2">
<Users size={16} className="text-brand-gold" />
Equipe ({selectedEvent.photographerIds.length})
</h4>
{(user.role === UserRole.BUSINESS_OWNER ||
user.role === UserRole.SUPERADMIN) && (
<button
onClick={handleManageTeam}
className="text-brand-gold hover:text-brand-black transition-colors"
title="Adicionar fotógrafo"
>
<PlusCircle size={18} />
</button>
)}
</div>
{(selectedEvent.assignments && selectedEvent.assignments.filter(a => a.status !== "REJEITADO").length > 0) ||
((user.role === UserRole.BUSINESS_OWNER || user.role === UserRole.SUPERADMIN) && selectedEvent.photographerIds.length > 0) ? (
<div className="border p-5 rounded bg-white">
<div className="flex justify-between items-center mb-3">
<h4 className="font-bold text-sm text-gray-700 flex items-center gap-2">
<Users size={16} className="text-brand-gold" />
Equipe ({(selectedEvent.assignments || []).filter(a => a.status !== "REJEITADO").length})
</h4>
{(user.role === UserRole.BUSINESS_OWNER ||
user.role === UserRole.SUPERADMIN) && (
<button
onClick={handleManageTeam}
className="text-brand-gold hover:text-brand-black transition-colors"
title="Adicionar fotógrafo"
>
<PlusCircle size={18} />
</button>
)}
</div>
{selectedEvent.photographerIds.length > 0 ? (
<div className="space-y-2">
{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 (
<div
key={assignment.professionalId}
className="flex items-center justify-between gap-2 text-sm mb-2"
>
<div
className="flex items-center gap-2 cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => handleViewProfessional(photographer!)}
>
<div
key={id}
className="flex items-center gap-2 text-sm"
>
<div
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=${id}`
})`,
backgroundSize: "cover",
}}
></div>
<span className="text-gray-700">
{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",
}}
></div>
<div className="flex flex-col">
<span className="text-gray-700 font-medium">
{photographer?.name || "Fotógrafo"}
</span>
<span className="text-xs text-gray-500">
{assignment.status === "PENDENTE" ? "Convite Pendente" : "Confirmado"}
</span>
</div>
);
})}
</div>
) : (
<p className="text-sm text-gray-400 italic">
Nenhum profissional atribuído
</p>
)}
</div>
)}
</div>
{assignment.status === "PENDENTE" && (
<span className="w-2 h-2 rounded-full bg-yellow-400" title="Pendente"></span>
)}
{assignment.status === "ACEITO" && (
<span className="w-2 h-2 rounded-full bg-green-500" title="Aceito"></span>
)}
</div>
);
})}
{(selectedEvent.assignments || []).filter(a => a.status !== "REJEITADO").length === 0 && (
<p className="text-sm text-gray-400 italic">
Nenhum profissional na equipe.
</p>
)}
</div>
) : null
}
</div>
</div>
</div>
@ -855,10 +873,12 @@ export const Dashboard: React.FC<DashboardProps> = ({
</thead>
<tbody>
{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<DashboardProps> = ({
className="border-b border-gray-100 hover:bg-gray-50 transition-colors"
>
{/* Profissional */}
<td className="p-4">
<td className="p-4 cursor-pointer" onClick={() => handleViewProfessional(photographer)}>
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-full border-2 border-gray-200 bg-gray-300 flex-shrink-0"
@ -901,13 +921,26 @@ export const Dashboard: React.FC<DashboardProps> = ({
{/* Status */}
<td className="p-4 text-center">
{isAssigned ? (
<span className="inline-flex items-center gap-1.5 px-3 py-1 bg-brand-gold/20 text-brand-black rounded-full text-xs font-medium">
<CheckCircle size={14} />
Atribuído
</span>
) : (
{status === "ACEITO" && (
<span className="inline-flex items-center gap-1.5 px-3 py-1 bg-green-100 text-green-800 rounded-full text-xs font-medium">
<CheckCircle size={14} />
Confirmado
</span>
)}
{status === "PENDENTE" && (
<span className="inline-flex items-center gap-1.5 px-3 py-1 bg-yellow-100 text-yellow-800 rounded-full text-xs font-medium">
<Clock size={14} />
Pendente
</span>
)}
{status === "REJEITADO" && (
<span className="inline-flex items-center gap-1.5 px-3 py-1 bg-red-100 text-red-800 rounded-full text-xs font-medium">
<X size={14} />
Recusado
</span>
)}
{!status && (
<span className="inline-flex items-center gap-1.5 px-3 py-1 bg-gray-100 text-gray-600 rounded-full text-xs font-medium">
<UserCheck size={14} />
Disponível
</span>
@ -920,15 +953,13 @@ export const Dashboard: React.FC<DashboardProps> = ({
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"}
</button>
</td>
</tr>
@ -967,6 +998,14 @@ export const Dashboard: React.FC<DashboardProps> = ({
</div>
</div>
)}
{viewingProfessional && (
<ProfessionalDetailsModal
professional={viewingProfessional}
isOpen={!!viewingProfessional}
onClose={() => setViewingProfessional(null)}
/>
)}
</div>
</div>
);