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:
parent
cd196a0275
commit
434548c158
17 changed files with 1094 additions and 74 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
}
|
||||
|
|
|
|||
83
backend/internal/availability/handler.go
Normal file
83
backend/internal/availability/handler.go
Normal 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)
|
||||
}
|
||||
75
backend/internal/availability/service.go
Normal file
75
backend/internal/availability/service.go
Normal 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},
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
117
backend/internal/db/generated/availability.sql.go
Normal file
117
backend/internal/db/generated/availability.sql.go
Normal 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
|
||||
}
|
||||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
21
backend/internal/db/queries/availability.sql
Normal file
21
backend/internal/db/queries/availability.sql
Normal 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;
|
||||
|
|
@ -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)
|
||||
);
|
||||
|
|
|
|||
123
frontend/components/ProfessionalDetailsModal.tsx
Normal file
123
frontend/components/ProfessionalDetailsModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue