Merge pull request #37 from rede5/Front-back-integracao-task15
feat(ops): implementa modulo operacional completo (escala, logistica, equipe) - Backend: Migrations para tabelas 'escalas' e 'logistica' (transporte) - Backend: Handlers e Services completos para gestão de escalas e logística - Backend: Suporte a auth vinculado a perfil profissional - Frontend: Nova página de Detalhes Operacionais (/agenda/:id) - Frontend: Componente EventScheduler com verificação robusta de conflitos - Frontend: Componente EventLogistics para gestão de motoristas e caronas - Frontend: Modal de Detalhes de Profissional unificado (Admin + Self-view) - Frontend: Dashboard com modal de gestão de equipe e filtros avançados - Fix: Correção crítica de timezone (UTC) em horários de agendamento - Fix: Tratamento de URLs no campo de local do evento - Fix: Filtros de profissional com carro na logística núcleo operacional da plataforma, permitindo o fluxo completo de agendamento e logística de eventos. ## Principais Implementações ### 1. Escala e Agendamento ([EventScheduler](cci:1://file:///c:/Projetos/photum/frontend/components/EventScheduler.tsx:21:0-276:2)) - Interface para alocação de profissionais em horários específicos. - **Sistema Anti-Conflito**: Bloqueia alocação de profissionais já ocupados em outros eventos no mesmo dia/horário. - Visualização de status (Confirmado/Pendente/Recusado). ### 2. Logística ([EventLogistics](cci:1://file:///c:/Projetos/photum/frontend/components/EventLogistics.tsx:22:0-209:2)) - Gestão de transporte e caronas. - Filtro inteligente: Apenas profissionais com `carro_disponivel` aparecem como opções de Motorista. ### 3. Gestão de Equipe e Detalhes - Modal unificado de detalhes do profissional, acessível via Painel e Escala. - **Self-View**: Profissionais podem visualizar seus próprios dados completos (incluindo financeiros e performance). - Gestores visualizam dados de todos. ### 4. Melhorias Técnicas e Fixes - **Timezone**: Correção definitiva para armazenamento de horários (conversão Local -> UTC correta). - **UX**: - Links de mapa detectados automaticamente no campo 'Local'. - Dropdowns com informações de função (e.g. "João - Fotógrafo"). - Feedback visual de status de conflito.
This commit is contained in:
commit
030b78d787
23 changed files with 2984 additions and 310 deletions
|
|
@ -14,7 +14,9 @@ import (
|
|||
"photum-backend/internal/cursos"
|
||||
"photum-backend/internal/db"
|
||||
"photum-backend/internal/empresas"
|
||||
"photum-backend/internal/escalas"
|
||||
"photum-backend/internal/funcoes"
|
||||
"photum-backend/internal/logistica"
|
||||
"photum-backend/internal/profissionais"
|
||||
"photum-backend/internal/storage"
|
||||
"photum-backend/internal/tipos_eventos"
|
||||
|
|
@ -92,6 +94,8 @@ func main() {
|
|||
cadastroFotHandler := cadastro_fot.NewHandler(cadastroFotService)
|
||||
agendaHandler := agenda.NewHandler(agendaService)
|
||||
availabilityHandler := availability.NewHandler(availabilityService)
|
||||
escalasHandler := escalas.NewHandler(escalas.NewService(queries))
|
||||
logisticaHandler := logistica.NewHandler(logistica.NewService(queries))
|
||||
|
||||
r := gin.Default()
|
||||
|
||||
|
|
@ -207,6 +211,25 @@ func main() {
|
|||
api.POST("/availability", availabilityHandler.SetAvailability)
|
||||
api.GET("/availability", availabilityHandler.ListAvailability)
|
||||
|
||||
// Escalas Routes
|
||||
api.POST("/escalas", escalasHandler.Create)
|
||||
api.GET("/escalas", escalasHandler.ListByAgenda)
|
||||
api.DELETE("/escalas/:id", escalasHandler.Delete)
|
||||
api.PUT("/escalas/:id", escalasHandler.Update)
|
||||
|
||||
// Logistics Routes
|
||||
logisticaGroup := api.Group("/logistica")
|
||||
{
|
||||
logisticaGroup.POST("/carros", logisticaHandler.CreateCarro)
|
||||
logisticaGroup.GET("/carros", logisticaHandler.ListCarros)
|
||||
logisticaGroup.DELETE("/carros/:id", logisticaHandler.DeleteCarro)
|
||||
logisticaGroup.PUT("/carros/:id", logisticaHandler.UpdateCarro)
|
||||
|
||||
logisticaGroup.POST("/carros/:id/passageiros", logisticaHandler.AddPassenger)
|
||||
logisticaGroup.DELETE("/carros/:id/passageiros/:profID", logisticaHandler.RemovePassenger)
|
||||
logisticaGroup.GET("/carros/:id/passageiros", logisticaHandler.ListPassengers)
|
||||
}
|
||||
|
||||
admin := api.Group("/admin")
|
||||
{
|
||||
admin.GET("/users", authHandler.ListUsers)
|
||||
|
|
|
|||
|
|
@ -1376,6 +1376,154 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"/api/escalas": {
|
||||
"get": {
|
||||
"description": "Get all schedule entries for a specific agenda_id",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"escalas"
|
||||
],
|
||||
"summary": "List schedules for an event",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Agenda ID",
|
||||
"name": "agenda_id",
|
||||
"in": "query",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"description": "Assign a professional to a time block in an event",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"escalas"
|
||||
],
|
||||
"summary": "Create a schedule entry",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Create Escala",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/escalas.CreateEscalaInput"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Created",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/escalas/{id}": {
|
||||
"put": {
|
||||
"description": "Update time or professional for a slot",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"escalas"
|
||||
],
|
||||
"summary": "Update a schedule entry",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Escala ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Update Escala",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/escalas.UpdateEscalaInput"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"description": "Remove a professional from a time block",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"escalas"
|
||||
],
|
||||
"summary": "Delete a schedule entry",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Escala ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/funcoes": {
|
||||
"get": {
|
||||
"description": "List all professional functions",
|
||||
|
|
@ -2942,6 +3090,45 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"escalas.CreateEscalaInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agenda_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"data_hora_fim": {
|
||||
"description": "RFC3339",
|
||||
"type": "string"
|
||||
},
|
||||
"data_hora_inicio": {
|
||||
"description": "RFC3339",
|
||||
"type": "string"
|
||||
},
|
||||
"funcao_especifica": {
|
||||
"type": "string"
|
||||
},
|
||||
"profissional_id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"escalas.UpdateEscalaInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data_hora_fim": {
|
||||
"type": "string"
|
||||
},
|
||||
"data_hora_inicio": {
|
||||
"type": "string"
|
||||
},
|
||||
"funcao_especifica": {
|
||||
"type": "string"
|
||||
},
|
||||
"profissional_id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"funcoes.CreateFuncaoRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
|
|
|||
|
|
@ -1370,6 +1370,154 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/api/escalas": {
|
||||
"get": {
|
||||
"description": "Get all schedule entries for a specific agenda_id",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"escalas"
|
||||
],
|
||||
"summary": "List schedules for an event",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Agenda ID",
|
||||
"name": "agenda_id",
|
||||
"in": "query",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"description": "Assign a professional to a time block in an event",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"escalas"
|
||||
],
|
||||
"summary": "Create a schedule entry",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Create Escala",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/escalas.CreateEscalaInput"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Created",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/escalas/{id}": {
|
||||
"put": {
|
||||
"description": "Update time or professional for a slot",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"escalas"
|
||||
],
|
||||
"summary": "Update a schedule entry",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Escala ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Update Escala",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/escalas.UpdateEscalaInput"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"description": "Remove a professional from a time block",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"escalas"
|
||||
],
|
||||
"summary": "Delete a schedule entry",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Escala ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/funcoes": {
|
||||
"get": {
|
||||
"description": "List all professional functions",
|
||||
|
|
@ -2936,6 +3084,45 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"escalas.CreateEscalaInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agenda_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"data_hora_fim": {
|
||||
"description": "RFC3339",
|
||||
"type": "string"
|
||||
},
|
||||
"data_hora_inicio": {
|
||||
"description": "RFC3339",
|
||||
"type": "string"
|
||||
},
|
||||
"funcao_especifica": {
|
||||
"type": "string"
|
||||
},
|
||||
"profissional_id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"escalas.UpdateEscalaInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data_hora_fim": {
|
||||
"type": "string"
|
||||
},
|
||||
"data_hora_inicio": {
|
||||
"type": "string"
|
||||
},
|
||||
"funcao_especifica": {
|
||||
"type": "string"
|
||||
},
|
||||
"profissional_id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"funcoes.CreateFuncaoRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
|
|
|||
|
|
@ -238,6 +238,32 @@ definitions:
|
|||
nome:
|
||||
type: string
|
||||
type: object
|
||||
escalas.CreateEscalaInput:
|
||||
properties:
|
||||
agenda_id:
|
||||
type: string
|
||||
data_hora_fim:
|
||||
description: RFC3339
|
||||
type: string
|
||||
data_hora_inicio:
|
||||
description: RFC3339
|
||||
type: string
|
||||
funcao_especifica:
|
||||
type: string
|
||||
profissional_id:
|
||||
type: string
|
||||
type: object
|
||||
escalas.UpdateEscalaInput:
|
||||
properties:
|
||||
data_hora_fim:
|
||||
type: string
|
||||
data_hora_inicio:
|
||||
type: string
|
||||
funcao_especifica:
|
||||
type: string
|
||||
profissional_id:
|
||||
type: string
|
||||
type: object
|
||||
funcoes.CreateFuncaoRequest:
|
||||
properties:
|
||||
nome:
|
||||
|
|
@ -1348,6 +1374,104 @@ paths:
|
|||
summary: Update a company
|
||||
tags:
|
||||
- empresas
|
||||
/api/escalas:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Get all schedule entries for a specific agenda_id
|
||||
parameters:
|
||||
- description: Agenda ID
|
||||
in: query
|
||||
name: agenda_id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
type: array
|
||||
summary: List schedules for an event
|
||||
tags:
|
||||
- escalas
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Assign a professional to a time block in an event
|
||||
parameters:
|
||||
- description: Create Escala
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/escalas.CreateEscalaInput'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"201":
|
||||
description: Created
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Create a schedule entry
|
||||
tags:
|
||||
- escalas
|
||||
/api/escalas/{id}:
|
||||
delete:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Remove a professional from a time block
|
||||
parameters:
|
||||
- description: Escala ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Delete a schedule entry
|
||||
tags:
|
||||
- escalas
|
||||
put:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Update time or professional for a slot
|
||||
parameters:
|
||||
- description: Escala ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
- description: Update Escala
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/escalas.UpdateEscalaInput'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Update a schedule entry
|
||||
tags:
|
||||
- escalas
|
||||
/api/funcoes:
|
||||
get:
|
||||
consumes:
|
||||
|
|
|
|||
326
backend/internal/db/generated/escalas.sql.go
Normal file
326
backend/internal/db/generated/escalas.sql.go
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: escalas.sql
|
||||
|
||||
package generated
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const createEscala = `-- name: CreateEscala :one
|
||||
INSERT INTO agenda_escalas (
|
||||
agenda_id, profissional_id, data_hora_inicio, data_hora_fim, funcao_especifica
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5
|
||||
)
|
||||
RETURNING id, agenda_id, profissional_id, data_hora_inicio, data_hora_fim, funcao_especifica, criado_em, atualizado_em
|
||||
`
|
||||
|
||||
type CreateEscalaParams struct {
|
||||
AgendaID pgtype.UUID `json:"agenda_id"`
|
||||
ProfissionalID pgtype.UUID `json:"profissional_id"`
|
||||
DataHoraInicio pgtype.Timestamptz `json:"data_hora_inicio"`
|
||||
DataHoraFim pgtype.Timestamptz `json:"data_hora_fim"`
|
||||
FuncaoEspecifica pgtype.Text `json:"funcao_especifica"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateEscala(ctx context.Context, arg CreateEscalaParams) (AgendaEscala, error) {
|
||||
row := q.db.QueryRow(ctx, createEscala,
|
||||
arg.AgendaID,
|
||||
arg.ProfissionalID,
|
||||
arg.DataHoraInicio,
|
||||
arg.DataHoraFim,
|
||||
arg.FuncaoEspecifica,
|
||||
)
|
||||
var i AgendaEscala
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.AgendaID,
|
||||
&i.ProfissionalID,
|
||||
&i.DataHoraInicio,
|
||||
&i.DataHoraFim,
|
||||
&i.FuncaoEspecifica,
|
||||
&i.CriadoEm,
|
||||
&i.AtualizadoEm,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const createMapa = `-- name: CreateMapa :one
|
||||
INSERT INTO mapas_eventos (agenda_id, nome, imagem_url)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, agenda_id, nome, imagem_url, criado_em
|
||||
`
|
||||
|
||||
type CreateMapaParams struct {
|
||||
AgendaID pgtype.UUID `json:"agenda_id"`
|
||||
Nome pgtype.Text `json:"nome"`
|
||||
ImagemUrl pgtype.Text `json:"imagem_url"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateMapa(ctx context.Context, arg CreateMapaParams) (MapasEvento, error) {
|
||||
row := q.db.QueryRow(ctx, createMapa, arg.AgendaID, arg.Nome, arg.ImagemUrl)
|
||||
var i MapasEvento
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.AgendaID,
|
||||
&i.Nome,
|
||||
&i.ImagemUrl,
|
||||
&i.CriadoEm,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteEscala = `-- name: DeleteEscala :exec
|
||||
DELETE FROM agenda_escalas
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteEscala(ctx context.Context, id pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteEscala, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteMapa = `-- name: DeleteMapa :exec
|
||||
DELETE FROM mapas_eventos WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteMapa(ctx context.Context, id pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteMapa, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteMarcador = `-- name: DeleteMarcador :exec
|
||||
DELETE FROM marcadores_mapa WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteMarcador(ctx context.Context, id pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteMarcador, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const listEscalasByAgendaID = `-- name: ListEscalasByAgendaID :many
|
||||
SELECT e.id, e.agenda_id, e.profissional_id, e.data_hora_inicio, e.data_hora_fim, e.funcao_especifica, e.criado_em, e.atualizado_em, p.nome as profissional_nome, p.avatar_url, p.whatsapp, f.nome as funcao_nome
|
||||
FROM agenda_escalas e
|
||||
JOIN cadastro_profissionais p ON e.profissional_id = p.id
|
||||
LEFT JOIN funcoes_profissionais f ON p.funcao_profissional_id = f.id
|
||||
WHERE e.agenda_id = $1
|
||||
ORDER BY e.data_hora_inicio
|
||||
`
|
||||
|
||||
type ListEscalasByAgendaIDRow struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
AgendaID pgtype.UUID `json:"agenda_id"`
|
||||
ProfissionalID pgtype.UUID `json:"profissional_id"`
|
||||
DataHoraInicio pgtype.Timestamptz `json:"data_hora_inicio"`
|
||||
DataHoraFim pgtype.Timestamptz `json:"data_hora_fim"`
|
||||
FuncaoEspecifica pgtype.Text `json:"funcao_especifica"`
|
||||
CriadoEm pgtype.Timestamptz `json:"criado_em"`
|
||||
AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"`
|
||||
ProfissionalNome string `json:"profissional_nome"`
|
||||
AvatarUrl pgtype.Text `json:"avatar_url"`
|
||||
Whatsapp pgtype.Text `json:"whatsapp"`
|
||||
FuncaoNome pgtype.Text `json:"funcao_nome"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListEscalasByAgendaID(ctx context.Context, agendaID pgtype.UUID) ([]ListEscalasByAgendaIDRow, error) {
|
||||
rows, err := q.db.Query(ctx, listEscalasByAgendaID, agendaID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListEscalasByAgendaIDRow
|
||||
for rows.Next() {
|
||||
var i ListEscalasByAgendaIDRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.AgendaID,
|
||||
&i.ProfissionalID,
|
||||
&i.DataHoraInicio,
|
||||
&i.DataHoraFim,
|
||||
&i.FuncaoEspecifica,
|
||||
&i.CriadoEm,
|
||||
&i.AtualizadoEm,
|
||||
&i.ProfissionalNome,
|
||||
&i.AvatarUrl,
|
||||
&i.Whatsapp,
|
||||
&i.FuncaoNome,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listMapasByAgendaID = `-- name: ListMapasByAgendaID :many
|
||||
SELECT id, agenda_id, nome, imagem_url, criado_em FROM mapas_eventos
|
||||
WHERE agenda_id = $1
|
||||
ORDER BY criado_em
|
||||
`
|
||||
|
||||
func (q *Queries) ListMapasByAgendaID(ctx context.Context, agendaID pgtype.UUID) ([]MapasEvento, error) {
|
||||
rows, err := q.db.Query(ctx, listMapasByAgendaID, agendaID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []MapasEvento
|
||||
for rows.Next() {
|
||||
var i MapasEvento
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.AgendaID,
|
||||
&i.Nome,
|
||||
&i.ImagemUrl,
|
||||
&i.CriadoEm,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listMarcadoresByMapaID = `-- name: ListMarcadoresByMapaID :many
|
||||
SELECT m.id, m.mapa_id, m.profissional_id, m.pos_x, m.pos_y, m.rotulo, m.criado_em, p.nome as profissional_nome, p.avatar_url, f.nome as funcao_nome
|
||||
FROM marcadores_mapa m
|
||||
LEFT JOIN cadastro_profissionais p ON m.profissional_id = p.id
|
||||
LEFT JOIN funcoes_profissionais f ON p.funcao_profissional_id = f.id
|
||||
WHERE m.mapa_id = $1
|
||||
`
|
||||
|
||||
type ListMarcadoresByMapaIDRow struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
MapaID pgtype.UUID `json:"mapa_id"`
|
||||
ProfissionalID pgtype.UUID `json:"profissional_id"`
|
||||
PosX pgtype.Numeric `json:"pos_x"`
|
||||
PosY pgtype.Numeric `json:"pos_y"`
|
||||
Rotulo pgtype.Text `json:"rotulo"`
|
||||
CriadoEm pgtype.Timestamptz `json:"criado_em"`
|
||||
ProfissionalNome pgtype.Text `json:"profissional_nome"`
|
||||
AvatarUrl pgtype.Text `json:"avatar_url"`
|
||||
FuncaoNome pgtype.Text `json:"funcao_nome"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListMarcadoresByMapaID(ctx context.Context, mapaID pgtype.UUID) ([]ListMarcadoresByMapaIDRow, error) {
|
||||
rows, err := q.db.Query(ctx, listMarcadoresByMapaID, mapaID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListMarcadoresByMapaIDRow
|
||||
for rows.Next() {
|
||||
var i ListMarcadoresByMapaIDRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.MapaID,
|
||||
&i.ProfissionalID,
|
||||
&i.PosX,
|
||||
&i.PosY,
|
||||
&i.Rotulo,
|
||||
&i.CriadoEm,
|
||||
&i.ProfissionalNome,
|
||||
&i.AvatarUrl,
|
||||
&i.FuncaoNome,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const updateEscala = `-- name: UpdateEscala :one
|
||||
UPDATE agenda_escalas
|
||||
SET profissional_id = COALESCE($2, profissional_id),
|
||||
data_hora_inicio = COALESCE($3, data_hora_inicio),
|
||||
data_hora_fim = COALESCE($4, data_hora_fim),
|
||||
funcao_especifica = COALESCE($5, funcao_especifica),
|
||||
atualizado_em = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id, agenda_id, profissional_id, data_hora_inicio, data_hora_fim, funcao_especifica, criado_em, atualizado_em
|
||||
`
|
||||
|
||||
type UpdateEscalaParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
ProfissionalID pgtype.UUID `json:"profissional_id"`
|
||||
DataHoraInicio pgtype.Timestamptz `json:"data_hora_inicio"`
|
||||
DataHoraFim pgtype.Timestamptz `json:"data_hora_fim"`
|
||||
FuncaoEspecifica pgtype.Text `json:"funcao_especifica"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateEscala(ctx context.Context, arg UpdateEscalaParams) (AgendaEscala, error) {
|
||||
row := q.db.QueryRow(ctx, updateEscala,
|
||||
arg.ID,
|
||||
arg.ProfissionalID,
|
||||
arg.DataHoraInicio,
|
||||
arg.DataHoraFim,
|
||||
arg.FuncaoEspecifica,
|
||||
)
|
||||
var i AgendaEscala
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.AgendaID,
|
||||
&i.ProfissionalID,
|
||||
&i.DataHoraInicio,
|
||||
&i.DataHoraFim,
|
||||
&i.FuncaoEspecifica,
|
||||
&i.CriadoEm,
|
||||
&i.AtualizadoEm,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const upsertMarcador = `-- name: UpsertMarcador :one
|
||||
INSERT INTO marcadores_mapa (mapa_id, profissional_id, pos_x, pos_y, rotulo)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
SET pos_x = EXCLUDED.pos_x,
|
||||
pos_y = EXCLUDED.pos_y,
|
||||
rotulo = EXCLUDED.rotulo,
|
||||
profissional_id = EXCLUDED.profissional_id
|
||||
RETURNING id, mapa_id, profissional_id, pos_x, pos_y, rotulo, criado_em
|
||||
`
|
||||
|
||||
type UpsertMarcadorParams struct {
|
||||
MapaID pgtype.UUID `json:"mapa_id"`
|
||||
ProfissionalID pgtype.UUID `json:"profissional_id"`
|
||||
PosX pgtype.Numeric `json:"pos_x"`
|
||||
PosY pgtype.Numeric `json:"pos_y"`
|
||||
Rotulo pgtype.Text `json:"rotulo"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertMarcador(ctx context.Context, arg UpsertMarcadorParams) (MarcadoresMapa, error) {
|
||||
row := q.db.QueryRow(ctx, upsertMarcador,
|
||||
arg.MapaID,
|
||||
arg.ProfissionalID,
|
||||
arg.PosX,
|
||||
arg.PosY,
|
||||
arg.Rotulo,
|
||||
)
|
||||
var i MarcadoresMapa
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.MapaID,
|
||||
&i.ProfissionalID,
|
||||
&i.PosX,
|
||||
&i.PosY,
|
||||
&i.Rotulo,
|
||||
&i.CriadoEm,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
237
backend/internal/db/generated/logistica.sql.go
Normal file
237
backend/internal/db/generated/logistica.sql.go
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: logistica.sql
|
||||
|
||||
package generated
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const addPassageiro = `-- name: AddPassageiro :one
|
||||
INSERT INTO logistica_passageiros (carro_id, profissional_id)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id, carro_id, profissional_id, criado_em
|
||||
`
|
||||
|
||||
type AddPassageiroParams struct {
|
||||
CarroID pgtype.UUID `json:"carro_id"`
|
||||
ProfissionalID pgtype.UUID `json:"profissional_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) AddPassageiro(ctx context.Context, arg AddPassageiroParams) (LogisticaPassageiro, error) {
|
||||
row := q.db.QueryRow(ctx, addPassageiro, arg.CarroID, arg.ProfissionalID)
|
||||
var i LogisticaPassageiro
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.CarroID,
|
||||
&i.ProfissionalID,
|
||||
&i.CriadoEm,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const createCarro = `-- name: CreateCarro :one
|
||||
INSERT INTO logistica_carros (
|
||||
agenda_id, motorista_id, nome_motorista, horario_chegada, observacoes
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5
|
||||
)
|
||||
RETURNING id, agenda_id, motorista_id, nome_motorista, horario_chegada, observacoes, criado_em, atualizado_em
|
||||
`
|
||||
|
||||
type CreateCarroParams struct {
|
||||
AgendaID pgtype.UUID `json:"agenda_id"`
|
||||
MotoristaID pgtype.UUID `json:"motorista_id"`
|
||||
NomeMotorista pgtype.Text `json:"nome_motorista"`
|
||||
HorarioChegada pgtype.Text `json:"horario_chegada"`
|
||||
Observacoes pgtype.Text `json:"observacoes"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateCarro(ctx context.Context, arg CreateCarroParams) (LogisticaCarro, error) {
|
||||
row := q.db.QueryRow(ctx, createCarro,
|
||||
arg.AgendaID,
|
||||
arg.MotoristaID,
|
||||
arg.NomeMotorista,
|
||||
arg.HorarioChegada,
|
||||
arg.Observacoes,
|
||||
)
|
||||
var i LogisticaCarro
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.AgendaID,
|
||||
&i.MotoristaID,
|
||||
&i.NomeMotorista,
|
||||
&i.HorarioChegada,
|
||||
&i.Observacoes,
|
||||
&i.CriadoEm,
|
||||
&i.AtualizadoEm,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteCarro = `-- name: DeleteCarro :exec
|
||||
DELETE FROM logistica_carros WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteCarro(ctx context.Context, id pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteCarro, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const listCarrosByAgendaID = `-- name: ListCarrosByAgendaID :many
|
||||
SELECT c.id, c.agenda_id, c.motorista_id, c.nome_motorista, c.horario_chegada, c.observacoes, c.criado_em, c.atualizado_em, p.nome as motorista_nome_sistema, p.avatar_url as motorista_avatar
|
||||
FROM logistica_carros c
|
||||
LEFT JOIN cadastro_profissionais p ON c.motorista_id = p.id
|
||||
WHERE c.agenda_id = $1
|
||||
ORDER BY c.criado_em
|
||||
`
|
||||
|
||||
type ListCarrosByAgendaIDRow struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
AgendaID pgtype.UUID `json:"agenda_id"`
|
||||
MotoristaID pgtype.UUID `json:"motorista_id"`
|
||||
NomeMotorista pgtype.Text `json:"nome_motorista"`
|
||||
HorarioChegada pgtype.Text `json:"horario_chegada"`
|
||||
Observacoes pgtype.Text `json:"observacoes"`
|
||||
CriadoEm pgtype.Timestamptz `json:"criado_em"`
|
||||
AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"`
|
||||
MotoristaNomeSistema pgtype.Text `json:"motorista_nome_sistema"`
|
||||
MotoristaAvatar pgtype.Text `json:"motorista_avatar"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListCarrosByAgendaID(ctx context.Context, agendaID pgtype.UUID) ([]ListCarrosByAgendaIDRow, error) {
|
||||
rows, err := q.db.Query(ctx, listCarrosByAgendaID, agendaID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListCarrosByAgendaIDRow
|
||||
for rows.Next() {
|
||||
var i ListCarrosByAgendaIDRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.AgendaID,
|
||||
&i.MotoristaID,
|
||||
&i.NomeMotorista,
|
||||
&i.HorarioChegada,
|
||||
&i.Observacoes,
|
||||
&i.CriadoEm,
|
||||
&i.AtualizadoEm,
|
||||
&i.MotoristaNomeSistema,
|
||||
&i.MotoristaAvatar,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listPassageirosByCarroID = `-- name: ListPassageirosByCarroID :many
|
||||
SELECT lp.id, lp.carro_id, lp.profissional_id, lp.criado_em, p.nome, p.avatar_url, f.nome as funcao_nome
|
||||
FROM logistica_passageiros lp
|
||||
JOIN cadastro_profissionais p ON lp.profissional_id = p.id
|
||||
LEFT JOIN funcoes_profissionais f ON p.funcao_profissional_id = f.id
|
||||
WHERE lp.carro_id = $1
|
||||
`
|
||||
|
||||
type ListPassageirosByCarroIDRow struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
CarroID pgtype.UUID `json:"carro_id"`
|
||||
ProfissionalID pgtype.UUID `json:"profissional_id"`
|
||||
CriadoEm pgtype.Timestamptz `json:"criado_em"`
|
||||
Nome string `json:"nome"`
|
||||
AvatarUrl pgtype.Text `json:"avatar_url"`
|
||||
FuncaoNome pgtype.Text `json:"funcao_nome"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListPassageirosByCarroID(ctx context.Context, carroID pgtype.UUID) ([]ListPassageirosByCarroIDRow, error) {
|
||||
rows, err := q.db.Query(ctx, listPassageirosByCarroID, carroID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListPassageirosByCarroIDRow
|
||||
for rows.Next() {
|
||||
var i ListPassageirosByCarroIDRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.CarroID,
|
||||
&i.ProfissionalID,
|
||||
&i.CriadoEm,
|
||||
&i.Nome,
|
||||
&i.AvatarUrl,
|
||||
&i.FuncaoNome,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const removePassageiro = `-- name: RemovePassageiro :exec
|
||||
DELETE FROM logistica_passageiros
|
||||
WHERE carro_id = $1 AND profissional_id = $2
|
||||
`
|
||||
|
||||
type RemovePassageiroParams struct {
|
||||
CarroID pgtype.UUID `json:"carro_id"`
|
||||
ProfissionalID pgtype.UUID `json:"profissional_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) RemovePassageiro(ctx context.Context, arg RemovePassageiroParams) error {
|
||||
_, err := q.db.Exec(ctx, removePassageiro, arg.CarroID, arg.ProfissionalID)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateCarro = `-- name: UpdateCarro :one
|
||||
UPDATE logistica_carros
|
||||
SET motorista_id = COALESCE($2, motorista_id),
|
||||
nome_motorista = COALESCE($3, nome_motorista),
|
||||
horario_chegada = COALESCE($4, horario_chegada),
|
||||
observacoes = COALESCE($5, observacoes),
|
||||
atualizado_em = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id, agenda_id, motorista_id, nome_motorista, horario_chegada, observacoes, criado_em, atualizado_em
|
||||
`
|
||||
|
||||
type UpdateCarroParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
MotoristaID pgtype.UUID `json:"motorista_id"`
|
||||
NomeMotorista pgtype.Text `json:"nome_motorista"`
|
||||
HorarioChegada pgtype.Text `json:"horario_chegada"`
|
||||
Observacoes pgtype.Text `json:"observacoes"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateCarro(ctx context.Context, arg UpdateCarroParams) (LogisticaCarro, error) {
|
||||
row := q.db.QueryRow(ctx, updateCarro,
|
||||
arg.ID,
|
||||
arg.MotoristaID,
|
||||
arg.NomeMotorista,
|
||||
arg.HorarioChegada,
|
||||
arg.Observacoes,
|
||||
)
|
||||
var i LogisticaCarro
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.AgendaID,
|
||||
&i.MotoristaID,
|
||||
&i.NomeMotorista,
|
||||
&i.HorarioChegada,
|
||||
&i.Observacoes,
|
||||
&i.CriadoEm,
|
||||
&i.AtualizadoEm,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -39,6 +39,17 @@ type Agenda struct {
|
|||
Status pgtype.Text `json:"status"`
|
||||
}
|
||||
|
||||
type AgendaEscala struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
AgendaID pgtype.UUID `json:"agenda_id"`
|
||||
ProfissionalID pgtype.UUID `json:"profissional_id"`
|
||||
DataHoraInicio pgtype.Timestamptz `json:"data_hora_inicio"`
|
||||
DataHoraFim pgtype.Timestamptz `json:"data_hora_fim"`
|
||||
FuncaoEspecifica pgtype.Text `json:"funcao_especifica"`
|
||||
CriadoEm pgtype.Timestamptz `json:"criado_em"`
|
||||
AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"`
|
||||
}
|
||||
|
||||
type AgendaProfissionai struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
AgendaID pgtype.UUID `json:"agenda_id"`
|
||||
|
|
@ -140,6 +151,42 @@ type FuncoesProfissionai struct {
|
|||
AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"`
|
||||
}
|
||||
|
||||
type LogisticaCarro struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
AgendaID pgtype.UUID `json:"agenda_id"`
|
||||
MotoristaID pgtype.UUID `json:"motorista_id"`
|
||||
NomeMotorista pgtype.Text `json:"nome_motorista"`
|
||||
HorarioChegada pgtype.Text `json:"horario_chegada"`
|
||||
Observacoes pgtype.Text `json:"observacoes"`
|
||||
CriadoEm pgtype.Timestamptz `json:"criado_em"`
|
||||
AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"`
|
||||
}
|
||||
|
||||
type LogisticaPassageiro struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
CarroID pgtype.UUID `json:"carro_id"`
|
||||
ProfissionalID pgtype.UUID `json:"profissional_id"`
|
||||
CriadoEm pgtype.Timestamptz `json:"criado_em"`
|
||||
}
|
||||
|
||||
type MapasEvento struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
AgendaID pgtype.UUID `json:"agenda_id"`
|
||||
Nome pgtype.Text `json:"nome"`
|
||||
ImagemUrl pgtype.Text `json:"imagem_url"`
|
||||
CriadoEm pgtype.Timestamptz `json:"criado_em"`
|
||||
}
|
||||
|
||||
type MarcadoresMapa struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
MapaID pgtype.UUID `json:"mapa_id"`
|
||||
ProfissionalID pgtype.UUID `json:"profissional_id"`
|
||||
PosX pgtype.Numeric `json:"pos_x"`
|
||||
PosY pgtype.Numeric `json:"pos_y"`
|
||||
Rotulo pgtype.Text `json:"rotulo"`
|
||||
CriadoEm pgtype.Timestamptz `json:"criado_em"`
|
||||
}
|
||||
|
||||
type PrecosTiposEvento struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
TipoEventoID pgtype.UUID `json:"tipo_evento_id"`
|
||||
|
|
|
|||
62
backend/internal/db/queries/escalas.sql
Normal file
62
backend/internal/db/queries/escalas.sql
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
-- name: CreateEscala :one
|
||||
INSERT INTO agenda_escalas (
|
||||
agenda_id, profissional_id, data_hora_inicio, data_hora_fim, funcao_especifica
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
-- name: ListEscalasByAgendaID :many
|
||||
SELECT e.*, p.nome as profissional_nome, p.avatar_url, p.whatsapp, f.nome as funcao_nome
|
||||
FROM agenda_escalas e
|
||||
JOIN cadastro_profissionais p ON e.profissional_id = p.id
|
||||
LEFT JOIN funcoes_profissionais f ON p.funcao_profissional_id = f.id
|
||||
WHERE e.agenda_id = $1
|
||||
ORDER BY e.data_hora_inicio;
|
||||
|
||||
-- name: DeleteEscala :exec
|
||||
DELETE FROM agenda_escalas
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: UpdateEscala :one
|
||||
UPDATE agenda_escalas
|
||||
SET profissional_id = COALESCE($2, profissional_id),
|
||||
data_hora_inicio = COALESCE($3, data_hora_inicio),
|
||||
data_hora_fim = COALESCE($4, data_hora_fim),
|
||||
funcao_especifica = COALESCE($5, funcao_especifica),
|
||||
atualizado_em = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: ListMapasByAgendaID :many
|
||||
SELECT * FROM mapas_eventos
|
||||
WHERE agenda_id = $1
|
||||
ORDER BY criado_em;
|
||||
|
||||
-- name: CreateMapa :one
|
||||
INSERT INTO mapas_eventos (agenda_id, nome, imagem_url)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeleteMapa :exec
|
||||
DELETE FROM mapas_eventos WHERE id = $1;
|
||||
|
||||
-- name: ListMarcadoresByMapaID :many
|
||||
SELECT m.*, p.nome as profissional_nome, p.avatar_url, f.nome as funcao_nome
|
||||
FROM marcadores_mapa m
|
||||
LEFT JOIN cadastro_profissionais p ON m.profissional_id = p.id
|
||||
LEFT JOIN funcoes_profissionais f ON p.funcao_profissional_id = f.id
|
||||
WHERE m.mapa_id = $1;
|
||||
|
||||
-- name: UpsertMarcador :one
|
||||
INSERT INTO marcadores_mapa (mapa_id, profissional_id, pos_x, pos_y, rotulo)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
SET pos_x = EXCLUDED.pos_x,
|
||||
pos_y = EXCLUDED.pos_y,
|
||||
rotulo = EXCLUDED.rotulo,
|
||||
profissional_id = EXCLUDED.profissional_id
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeleteMarcador :exec
|
||||
DELETE FROM marcadores_mapa WHERE id = $1;
|
||||
43
backend/internal/db/queries/logistica.sql
Normal file
43
backend/internal/db/queries/logistica.sql
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
-- name: CreateCarro :one
|
||||
INSERT INTO logistica_carros (
|
||||
agenda_id, motorista_id, nome_motorista, horario_chegada, observacoes
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
-- name: ListCarrosByAgendaID :many
|
||||
SELECT c.*, p.nome as motorista_nome_sistema, p.avatar_url as motorista_avatar
|
||||
FROM logistica_carros c
|
||||
LEFT JOIN cadastro_profissionais p ON c.motorista_id = p.id
|
||||
WHERE c.agenda_id = $1
|
||||
ORDER BY c.criado_em;
|
||||
|
||||
-- name: UpdateCarro :one
|
||||
UPDATE logistica_carros
|
||||
SET motorista_id = COALESCE($2, motorista_id),
|
||||
nome_motorista = COALESCE($3, nome_motorista),
|
||||
horario_chegada = COALESCE($4, horario_chegada),
|
||||
observacoes = COALESCE($5, observacoes),
|
||||
atualizado_em = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeleteCarro :exec
|
||||
DELETE FROM logistica_carros WHERE id = $1;
|
||||
|
||||
-- name: AddPassageiro :one
|
||||
INSERT INTO logistica_passageiros (carro_id, profissional_id)
|
||||
VALUES ($1, $2)
|
||||
RETURNING *;
|
||||
|
||||
-- name: RemovePassageiro :exec
|
||||
DELETE FROM logistica_passageiros
|
||||
WHERE carro_id = $1 AND profissional_id = $2;
|
||||
|
||||
-- name: ListPassageirosByCarroID :many
|
||||
SELECT lp.*, p.nome, p.avatar_url, f.nome as funcao_nome
|
||||
FROM logistica_passageiros lp
|
||||
JOIN cadastro_profissionais p ON lp.profissional_id = p.id
|
||||
LEFT JOIN funcoes_profissionais f ON p.funcao_profissional_id = f.id
|
||||
WHERE lp.carro_id = $1;
|
||||
|
|
@ -368,3 +368,56 @@ CREATE TABLE IF NOT EXISTS disponibilidade_profissionais (
|
|||
criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(usuario_id, data)
|
||||
);
|
||||
|
||||
-- Agenda Escalas (Time blocks for professionals in an event)
|
||||
CREATE TABLE IF NOT EXISTS agenda_escalas (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
agenda_id UUID NOT NULL REFERENCES agenda(id) ON DELETE CASCADE,
|
||||
profissional_id UUID NOT NULL REFERENCES cadastro_profissionais(id) ON DELETE CASCADE,
|
||||
data_hora_inicio TIMESTAMPTZ NOT NULL,
|
||||
data_hora_fim TIMESTAMPTZ NOT NULL,
|
||||
funcao_especifica VARCHAR(100), -- e.g. "PalcoDireito", "Entrada"
|
||||
criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
atualizado_em TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Mapas de Eventos (Floor plans)
|
||||
CREATE TABLE IF NOT EXISTS mapas_eventos (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
agenda_id UUID NOT NULL REFERENCES agenda(id) ON DELETE CASCADE,
|
||||
nome VARCHAR(100), -- e.g. "Salão Principal"
|
||||
imagem_url TEXT,
|
||||
criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Marcadores no Mapa (Pins)
|
||||
CREATE TABLE IF NOT EXISTS marcadores_mapa (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
mapa_id UUID NOT NULL REFERENCES mapas_eventos(id) ON DELETE CASCADE,
|
||||
profissional_id UUID REFERENCES cadastro_profissionais(id) ON DELETE CASCADE,
|
||||
pos_x NUMERIC(5,2), -- Percentage 0-100
|
||||
pos_y NUMERIC(5,2),
|
||||
rotulo VARCHAR(50),
|
||||
criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Logística de Transporte (Cars)
|
||||
CREATE TABLE IF NOT EXISTS logistica_carros (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
agenda_id UUID NOT NULL REFERENCES agenda(id) ON DELETE CASCADE,
|
||||
motorista_id UUID REFERENCES cadastro_profissionais(id) ON DELETE SET NULL, -- Driver is usually a professional
|
||||
nome_motorista VARCHAR(255), -- Fallback if not a system professional or just custom name
|
||||
horario_chegada VARCHAR(20), -- e.g. "7h00"
|
||||
observacoes TEXT,
|
||||
criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
atualizado_em TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Passageiros (Passengers in the car)
|
||||
CREATE TABLE IF NOT EXISTS logistica_passageiros (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
carro_id UUID NOT NULL REFERENCES logistica_carros(id) ON DELETE CASCADE,
|
||||
profissional_id UUID NOT NULL REFERENCES cadastro_profissionais(id) ON DELETE CASCADE,
|
||||
criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(carro_id, profissional_id)
|
||||
);
|
||||
|
|
|
|||
158
backend/internal/escalas/handler.go
Normal file
158
backend/internal/escalas/handler.go
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
package escalas
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
func NewHandler(service *Service) *Handler {
|
||||
return &Handler{service: service}
|
||||
}
|
||||
|
||||
// Create godoc
|
||||
// @Summary Create a schedule entry
|
||||
// @Description Assign a professional to a time block in an event
|
||||
// @Tags escalas
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body CreateEscalaInput true "Create Escala"
|
||||
// @Success 201 {object} map[string]string
|
||||
// @Router /api/escalas [post]
|
||||
func (h *Handler) Create(c *gin.Context) {
|
||||
var req CreateEscalaInput
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
escala, err := h.service.Create(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"id": uuid.UUID(escala.ID.Bytes).String()})
|
||||
}
|
||||
|
||||
// ListByAgenda godoc
|
||||
// @Summary List schedules for an event
|
||||
// @Description Get all schedule entries for a specific agenda_id
|
||||
// @Tags escalas
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param agenda_id query string true "Agenda ID"
|
||||
// @Success 200 {array} map[string]interface{}
|
||||
// @Router /api/escalas [get]
|
||||
func (h *Handler) ListByAgenda(c *gin.Context) {
|
||||
agendaID := c.Query("agenda_id")
|
||||
if agendaID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "agenda_id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
escalas, err := h.service.ListByAgenda(c.Request.Context(), agendaID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
resp := make([]map[string]interface{}, len(escalas))
|
||||
for i, e := range escalas {
|
||||
var funcao string
|
||||
if e.FuncaoEspecifica.Valid {
|
||||
funcao = e.FuncaoEspecifica.String
|
||||
}
|
||||
var profNome string
|
||||
profNome = e.ProfissionalNome
|
||||
var avatar string
|
||||
if e.AvatarUrl.Valid {
|
||||
avatar = e.AvatarUrl.String
|
||||
}
|
||||
var phone string
|
||||
if e.Whatsapp.Valid {
|
||||
phone = e.Whatsapp.String
|
||||
}
|
||||
var profFuncao string
|
||||
if e.FuncaoNome.Valid {
|
||||
profFuncao = e.FuncaoNome.String
|
||||
}
|
||||
|
||||
resp[i] = map[string]interface{}{
|
||||
"id": uuid.UUID(e.ID.Bytes).String(),
|
||||
"agenda_id": uuid.UUID(e.AgendaID.Bytes).String(),
|
||||
"profissional_id": uuid.UUID(e.ProfissionalID.Bytes).String(),
|
||||
"profissional_nome": profNome,
|
||||
"avatar_url": avatar,
|
||||
"phone": phone,
|
||||
"profissional_role": profFuncao,
|
||||
"start": e.DataHoraInicio.Time,
|
||||
"end": e.DataHoraFim.Time,
|
||||
"role": funcao, // Specific role in this slot
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// Delete godoc
|
||||
// @Summary Delete a schedule entry
|
||||
// @Description Remove a professional from a time block
|
||||
// @Tags escalas
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Escala ID"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Router /api/escalas/{id} [delete]
|
||||
func (h *Handler) Delete(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "id required"})
|
||||
return
|
||||
}
|
||||
|
||||
err := h.service.Delete(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||||
}
|
||||
|
||||
// Update godoc
|
||||
// @Summary Update a schedule entry
|
||||
// @Description Update time or professional for a slot
|
||||
// @Tags escalas
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Escala ID"
|
||||
// @Param request body UpdateEscalaInput true "Update Escala"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Router /api/escalas/{id} [put]
|
||||
func (h *Handler) Update(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "id required"})
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateEscalaInput
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
_, err := h.service.Update(c.Request.Context(), id, req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "updated"})
|
||||
}
|
||||
126
backend/internal/escalas/service.go
Normal file
126
backend/internal/escalas/service.go
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
package escalas
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"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 CreateEscalaInput struct {
|
||||
AgendaID string `json:"agenda_id"`
|
||||
ProfissionalID string `json:"profissional_id"`
|
||||
DataHoraInicio string `json:"data_hora_inicio"` // RFC3339
|
||||
DataHoraFim string `json:"data_hora_fim"` // RFC3339
|
||||
FuncaoEspecifica string `json:"funcao_especifica"`
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, input CreateEscalaInput) (*generated.AgendaEscala, error) {
|
||||
agendaID, err := uuid.Parse(input.AgendaID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profID, err := uuid.Parse(input.ProfissionalID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var start, end pgtype.Timestamptz
|
||||
err = start.Scan(input.DataHoraInicio)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = end.Scan(input.DataHoraFim)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var funcao pgtype.Text
|
||||
if input.FuncaoEspecifica != "" {
|
||||
funcao = pgtype.Text{String: input.FuncaoEspecifica, Valid: true}
|
||||
}
|
||||
|
||||
escala, err := s.queries.CreateEscala(ctx, generated.CreateEscalaParams{
|
||||
AgendaID: pgtype.UUID{Bytes: agendaID, Valid: true},
|
||||
ProfissionalID: pgtype.UUID{Bytes: profID, Valid: true},
|
||||
DataHoraInicio: start,
|
||||
DataHoraFim: end,
|
||||
FuncaoEspecifica: funcao,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &escala, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListByAgenda(ctx context.Context, agendaID string) ([]generated.ListEscalasByAgendaIDRow, error) {
|
||||
parsedUUID, err := uuid.Parse(agendaID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.queries.ListEscalasByAgendaID(ctx, pgtype.UUID{Bytes: parsedUUID, Valid: true})
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, id string) error {
|
||||
parsedUUID, err := uuid.Parse(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.queries.DeleteEscala(ctx, pgtype.UUID{Bytes: parsedUUID, Valid: true})
|
||||
}
|
||||
|
||||
type UpdateEscalaInput struct {
|
||||
ProfissionalID *string `json:"profissional_id"`
|
||||
DataHoraInicio *string `json:"data_hora_inicio"`
|
||||
DataHoraFim *string `json:"data_hora_fim"`
|
||||
FuncaoEspecifica *string `json:"funcao_especifica"`
|
||||
}
|
||||
|
||||
func (s *Service) Update(ctx context.Context, id string, input UpdateEscalaInput) (*generated.AgendaEscala, error) {
|
||||
parsedUUID, err := uuid.Parse(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
params := generated.UpdateEscalaParams{
|
||||
ID: pgtype.UUID{Bytes: parsedUUID, Valid: true},
|
||||
}
|
||||
|
||||
if input.ProfissionalID != nil {
|
||||
parsedProfID, err := uuid.Parse(*input.ProfissionalID)
|
||||
if err == nil {
|
||||
params.ProfissionalID = pgtype.UUID{Bytes: parsedProfID, Valid: true}
|
||||
}
|
||||
}
|
||||
if input.DataHoraInicio != nil {
|
||||
var t pgtype.Timestamptz
|
||||
if err := t.Scan(*input.DataHoraInicio); err == nil {
|
||||
params.DataHoraInicio = t
|
||||
}
|
||||
}
|
||||
if input.DataHoraFim != nil {
|
||||
var t pgtype.Timestamptz
|
||||
if err := t.Scan(*input.DataHoraFim); err == nil {
|
||||
params.DataHoraFim = t
|
||||
}
|
||||
}
|
||||
if input.FuncaoEspecifica != nil {
|
||||
params.FuncaoEspecifica = pgtype.Text{String: *input.FuncaoEspecifica, Valid: true}
|
||||
}
|
||||
|
||||
escala, err := s.queries.UpdateEscala(ctx, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &escala, nil
|
||||
}
|
||||
164
backend/internal/logistica/handler.go
Normal file
164
backend/internal/logistica/handler.go
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
package logistica
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
func NewHandler(service *Service) *Handler {
|
||||
return &Handler{service: service}
|
||||
}
|
||||
|
||||
// CreateCarro
|
||||
func (h *Handler) CreateCarro(c *gin.Context) {
|
||||
var req CreateCarroInput
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
carro, err := h.service.CreateCarro(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"id": uuid.UUID(carro.ID.Bytes).String()})
|
||||
}
|
||||
|
||||
// ListCarros
|
||||
func (h *Handler) ListCarros(c *gin.Context) {
|
||||
agendaID := c.Query("agenda_id")
|
||||
if agendaID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "agenda_id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
carros, err := h.service.ListCarros(c.Request.Context(), agendaID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch passengers for each car? Or let frontend do it on demand?
|
||||
// For main list, efficient to just list cars, or maybe passengers embedded.
|
||||
// For simplicity, let's embed passengers if possible, but list returns row.
|
||||
// We will return list of cars. The frontend can fetch passengers per car or we should use a transaction/composite query.
|
||||
// MVP: Check if we can just return the cars now.
|
||||
|
||||
resp := make([]map[string]interface{}, len(carros))
|
||||
for i, car := range carros {
|
||||
var driverName string
|
||||
// Logic: if system driver name exists, use it. Else use custom name.
|
||||
if car.MotoristaNomeSistema.String != "" {
|
||||
driverName = car.MotoristaNomeSistema.String
|
||||
} else {
|
||||
driverName = car.NomeMotorista.String
|
||||
}
|
||||
|
||||
var avatar string
|
||||
if car.MotoristaAvatar.Valid {
|
||||
avatar = car.MotoristaAvatar.String
|
||||
}
|
||||
|
||||
resp[i] = map[string]interface{}{
|
||||
"id": uuid.UUID(car.ID.Bytes).String(),
|
||||
"agenda_id": uuid.UUID(car.AgendaID.Bytes).String(),
|
||||
"driver_id": uuid.UUID(car.MotoristaID.Bytes).String(), // May be empty UUID or nil representation
|
||||
"driver_name": driverName,
|
||||
"driver_avatar": avatar,
|
||||
"arrival_time": car.HorarioChegada.String,
|
||||
"notes": car.Observacoes.String,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// DeleteCarro
|
||||
func (h *Handler) DeleteCarro(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "id required"})
|
||||
return
|
||||
}
|
||||
if err := h.service.DeleteCarro(c.Request.Context(), id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||||
}
|
||||
|
||||
// UpdateCarro
|
||||
func (h *Handler) UpdateCarro(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var req UpdateCarroInput
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
_, err := h.service.UpdateCarro(c.Request.Context(), id, req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "updated"})
|
||||
}
|
||||
|
||||
// Passengers
|
||||
type AddPassengerInput struct {
|
||||
ProfissionalID string `json:"profissional_id"`
|
||||
}
|
||||
|
||||
func (h *Handler) AddPassenger(c *gin.Context) {
|
||||
carID := c.Param("id")
|
||||
var req AddPassengerInput
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
err := h.service.AddPassageiro(c.Request.Context(), carID, req.ProfissionalID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "passenger added"})
|
||||
}
|
||||
|
||||
func (h *Handler) RemovePassenger(c *gin.Context) {
|
||||
carID := c.Param("id")
|
||||
profID := c.Param("profID")
|
||||
err := h.service.RemovePassageiro(c.Request.Context(), carID, profID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "passenger removed"})
|
||||
}
|
||||
|
||||
func (h *Handler) ListPassengers(c *gin.Context) {
|
||||
carID := c.Param("id")
|
||||
passengers, err := h.service.ListPassageiros(c.Request.Context(), carID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
resp := make([]map[string]interface{}, len(passengers))
|
||||
for i, p := range passengers {
|
||||
resp[i] = map[string]interface{}{
|
||||
"id": uuid.UUID(p.ID.Bytes).String(),
|
||||
"profissional_id": uuid.UUID(p.ProfissionalID.Bytes).String(),
|
||||
"name": p.Nome,
|
||||
"avatar_url": p.AvatarUrl.String,
|
||||
"role": p.FuncaoNome.String,
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
158
backend/internal/logistica/service.go
Normal file
158
backend/internal/logistica/service.go
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
package logistica
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"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 CreateCarroInput struct {
|
||||
AgendaID string `json:"agenda_id"`
|
||||
MotoristaID *string `json:"motorista_id"`
|
||||
NomeMotorista *string `json:"nome_motorista"`
|
||||
HorarioChegada *string `json:"horario_chegada"`
|
||||
Observacoes *string `json:"observacoes"`
|
||||
}
|
||||
|
||||
func (s *Service) CreateCarro(ctx context.Context, input CreateCarroInput) (*generated.LogisticaCarro, error) {
|
||||
agendaID, err := uuid.Parse(input.AgendaID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
params := generated.CreateCarroParams{
|
||||
AgendaID: pgtype.UUID{Bytes: agendaID, Valid: true},
|
||||
}
|
||||
|
||||
if input.MotoristaID != nil && *input.MotoristaID != "" {
|
||||
if parsed, err := uuid.Parse(*input.MotoristaID); err == nil {
|
||||
params.MotoristaID = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||
}
|
||||
}
|
||||
if input.NomeMotorista != nil {
|
||||
params.NomeMotorista = pgtype.Text{String: *input.NomeMotorista, Valid: true}
|
||||
}
|
||||
if input.HorarioChegada != nil {
|
||||
params.HorarioChegada = pgtype.Text{String: *input.HorarioChegada, Valid: true}
|
||||
}
|
||||
if input.Observacoes != nil {
|
||||
params.Observacoes = pgtype.Text{String: *input.Observacoes, Valid: true}
|
||||
}
|
||||
|
||||
carro, err := s.queries.CreateCarro(ctx, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &carro, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListCarros(ctx context.Context, agendaID string) ([]generated.ListCarrosByAgendaIDRow, error) {
|
||||
parsedUUID, err := uuid.Parse(agendaID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.queries.ListCarrosByAgendaID(ctx, pgtype.UUID{Bytes: parsedUUID, Valid: true})
|
||||
}
|
||||
|
||||
func (s *Service) DeleteCarro(ctx context.Context, id string) error {
|
||||
parsedUUID, err := uuid.Parse(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.queries.DeleteCarro(ctx, pgtype.UUID{Bytes: parsedUUID, Valid: true})
|
||||
}
|
||||
|
||||
// UpdateCarroInput matches the update fields
|
||||
type UpdateCarroInput struct {
|
||||
MotoristaID *string `json:"motorista_id"`
|
||||
NomeMotorista *string `json:"nome_motorista"`
|
||||
HorarioChegada *string `json:"horario_chegada"`
|
||||
Observacoes *string `json:"observacoes"`
|
||||
}
|
||||
|
||||
func (s *Service) UpdateCarro(ctx context.Context, id string, input UpdateCarroInput) (*generated.LogisticaCarro, error) {
|
||||
parsedUUID, err := uuid.Parse(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
params := generated.UpdateCarroParams{
|
||||
ID: pgtype.UUID{Bytes: parsedUUID, Valid: true},
|
||||
}
|
||||
|
||||
if input.MotoristaID != nil {
|
||||
if *input.MotoristaID == "" {
|
||||
params.MotoristaID = pgtype.UUID{Valid: false}
|
||||
} else {
|
||||
if parsed, err := uuid.Parse(*input.MotoristaID); err == nil {
|
||||
params.MotoristaID = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||
}
|
||||
}
|
||||
}
|
||||
if input.NomeMotorista != nil {
|
||||
params.NomeMotorista = pgtype.Text{String: *input.NomeMotorista, Valid: true}
|
||||
}
|
||||
if input.HorarioChegada != nil {
|
||||
params.HorarioChegada = pgtype.Text{String: *input.HorarioChegada, Valid: true}
|
||||
}
|
||||
if input.Observacoes != nil {
|
||||
params.Observacoes = pgtype.Text{String: *input.Observacoes, Valid: true}
|
||||
}
|
||||
|
||||
carro, err := s.queries.UpdateCarro(ctx, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &carro, nil
|
||||
}
|
||||
|
||||
func (s *Service) AddPassageiro(ctx context.Context, carroID, profissionalID string) error {
|
||||
cID, err := uuid.Parse(carroID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pID, err := uuid.Parse(profissionalID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = s.queries.AddPassageiro(ctx, generated.AddPassageiroParams{
|
||||
CarroID: pgtype.UUID{Bytes: cID, Valid: true},
|
||||
ProfissionalID: pgtype.UUID{Bytes: pID, Valid: true},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) RemovePassageiro(ctx context.Context, carroID, profissionalID string) error {
|
||||
cID, err := uuid.Parse(carroID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pID, err := uuid.Parse(profissionalID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.queries.RemovePassageiro(ctx, generated.RemovePassageiroParams{
|
||||
CarroID: pgtype.UUID{Bytes: cID, Valid: true},
|
||||
ProfissionalID: pgtype.UUID{Bytes: pID, Valid: true},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) ListPassageiros(ctx context.Context, carroID string) ([]generated.ListPassageirosByCarroIDRow, error) {
|
||||
cID, err := uuid.Parse(carroID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.queries.ListPassageirosByCarroID(ctx, pgtype.UUID{Bytes: cID, Valid: true})
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import { Login } from "./pages/Login";
|
|||
import { Register } from "./pages/Register";
|
||||
import { ProfessionalRegister } from "./pages/ProfessionalRegister";
|
||||
import { TeamPage } from "./pages/Team";
|
||||
import EventDetails from "./pages/EventDetails";
|
||||
import Finance from "./pages/Finance";
|
||||
import PhotographerFinance from "./pages/PhotographerFinance";
|
||||
import { SettingsPage } from "./pages/Settings";
|
||||
|
|
@ -431,6 +432,17 @@ const AppContent: React.FC = () => {
|
|||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/agenda/:id"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<PageWrapper>
|
||||
<EventDetails />
|
||||
</PageWrapper>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
{/* Rota de solicitação de evento - Clientes e Administradores */}
|
||||
<Route
|
||||
|
|
@ -528,7 +540,7 @@ const AppContent: React.FC = () => {
|
|||
allowedRoles={[UserRole.SUPERADMIN, UserRole.BUSINESS_OWNER]}
|
||||
>
|
||||
<PageWrapper>
|
||||
<AccessCodeManagement onNavigate={(page) => {}} />
|
||||
<AccessCodeManagement onNavigate={(page) => { }} />
|
||||
</PageWrapper>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
|
|
|
|||
212
frontend/components/EventLogistics.tsx
Normal file
212
frontend/components/EventLogistics.tsx
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Plus, Trash, User, Truck, Car } from "lucide-react";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { listCarros, createCarro, deleteCarro, addPassenger, removePassenger, listPassengers, listCarros as fetchCarrosApi } from "../services/apiService";
|
||||
import { useData } from "../contexts/DataContext";
|
||||
import { UserRole } from "../types";
|
||||
|
||||
interface EventLogisticsProps {
|
||||
agendaId: string;
|
||||
assignedProfessionals?: string[];
|
||||
}
|
||||
|
||||
interface Carro {
|
||||
id: string;
|
||||
driver_id: string;
|
||||
driver_name: string;
|
||||
driver_avatar: string;
|
||||
arrival_time: string;
|
||||
notes: string;
|
||||
passengers: any[]; // We will fetch and attach
|
||||
}
|
||||
|
||||
const EventLogistics: React.FC<EventLogisticsProps> = ({ agendaId, assignedProfessionals }) => {
|
||||
const { token, user } = useAuth();
|
||||
const { professionals } = useData();
|
||||
const [carros, setCarros] = useState<Carro[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// New Car State
|
||||
const [driverId, setDriverId] = useState("");
|
||||
const [arrivalTime, setArrivalTime] = useState("07:00");
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
const isEditable = user?.role === UserRole.SUPERADMIN || user?.role === UserRole.BUSINESS_OWNER;
|
||||
|
||||
useEffect(() => {
|
||||
if (agendaId && token) {
|
||||
loadCarros();
|
||||
}
|
||||
}, [agendaId, token]);
|
||||
|
||||
const loadCarros = async () => {
|
||||
setLoading(true);
|
||||
const res = await fetchCarrosApi(agendaId, token!);
|
||||
if (res.data) {
|
||||
// For each car, fetch passengers
|
||||
const carsWithPassengers = await Promise.all(res.data.map(async (car: any) => {
|
||||
const passRes = await listPassengers(car.id, token!);
|
||||
return { ...car, passengers: passRes.data || [] };
|
||||
}));
|
||||
setCarros(carsWithPassengers);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleAddCarro = async () => {
|
||||
// Driver ID is optional (could be external), but for now select from professionals
|
||||
const input = {
|
||||
agenda_id: agendaId,
|
||||
motorista_id: driverId || undefined,
|
||||
horario_chegada: arrivalTime,
|
||||
observacoes: notes
|
||||
};
|
||||
const res = await createCarro(input, token!);
|
||||
if (res.data) {
|
||||
loadCarros();
|
||||
setDriverId("");
|
||||
setNotes("");
|
||||
} else {
|
||||
alert("Erro ao criar carro: " + res.error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCarro = async (id: string) => {
|
||||
if (confirm("Remover este carro e passageiros?")) {
|
||||
await deleteCarro(id, token!);
|
||||
loadCarros();
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddPassenger = async (carId: string, profId: string) => {
|
||||
if (!profId) return;
|
||||
const res = await addPassenger(carId, profId, token!);
|
||||
if (!res.error) {
|
||||
loadCarros();
|
||||
} else {
|
||||
alert("Erro ao adicionar passageiro: " + res.error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemovePassenger = async (carId: string, profId: string) => {
|
||||
const res = await removePassenger(carId, profId, token!);
|
||||
if (!res.error) {
|
||||
loadCarros();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white p-4 rounded-lg shadow space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-800 flex items-center">
|
||||
<Truck className="w-5 h-5 mr-2 text-orange-500" />
|
||||
Logística de Transporte
|
||||
</h3>
|
||||
|
||||
{/* Add Car Form - Only for Admins */}
|
||||
{isEditable && (
|
||||
<div className="bg-gray-50 p-3 rounded-md flex flex-wrap gap-2 items-end">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<label className="text-xs text-gray-500">Motorista (Opcional)</label>
|
||||
<select
|
||||
className="w-full p-2 rounded border bg-white"
|
||||
value={driverId}
|
||||
onChange={e => setDriverId(e.target.value)}
|
||||
>
|
||||
<option value="">Selecione ou deixe vazio...</option>
|
||||
{professionals
|
||||
.filter(p => p.carro_disponivel)
|
||||
.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.nomeEventos || p.nome}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="w-24">
|
||||
<label className="text-xs text-gray-500">Chegada</label>
|
||||
<input
|
||||
type="time"
|
||||
className="w-full p-2 rounded border bg-white"
|
||||
value={arrivalTime}
|
||||
onChange={e => setArrivalTime(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddCarro}
|
||||
className="bg-orange-600 hover:bg-orange-700 text-white p-2 rounded flex items-center"
|
||||
>
|
||||
<Plus size={20} /> <span className="ml-1 text-sm">Carro</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cars List */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{loading ? <p>Carregando...</p> : carros.map(car => (
|
||||
<div key={car.id} className="border rounded-lg p-3 bg-gray-50">
|
||||
<div className="flex justify-between items-start mb-2 border-b pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-orange-100 p-1.5 rounded-full">
|
||||
<Car className="w-4 h-4 text-orange-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-sm text-gray-800">
|
||||
{car.driver_name || "Motorista não definido"}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">Chegada: {car.arrival_time}</p>
|
||||
</div>
|
||||
</div>
|
||||
{isEditable && (
|
||||
<button onClick={() => handleDeleteCarro(car.id)} className="text-gray-400 hover:text-red-500">
|
||||
<Trash size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Passengers */}
|
||||
<div className="space-y-1 mb-3">
|
||||
<p className="text-xs font-semibold text-gray-500 uppercase">Passageiros</p>
|
||||
{car.passengers.length === 0 && <p className="text-xs italic text-gray-400">Vazio</p>}
|
||||
{car.passengers.map((p: any) => (
|
||||
<div key={p.id} className="flex justify-between items-center text-sm bg-white p-1 rounded px-2 border">
|
||||
<span className="truncate">{p.name || "Desconhecido"}</span>
|
||||
{isEditable && (
|
||||
<button onClick={() => handleRemovePassenger(car.id, p.profissional_id)} className="text-red-400 hover:text-red-600">
|
||||
<Trash size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add Passenger - Only for Admins */}
|
||||
{isEditable && (
|
||||
<select
|
||||
className="w-full text-xs p-1 rounded border bg-white"
|
||||
onChange={(e) => {
|
||||
if (e.target.value) handleAddPassenger(car.id, e.target.value);
|
||||
e.target.value = "";
|
||||
}}
|
||||
>
|
||||
<option value="">+ Adicionar Passageiro</option>
|
||||
{professionals
|
||||
.filter(p => {
|
||||
// Filter 1: Must be assigned to event (if restriction list provided)
|
||||
// If assignedProfessionals prop is missing or empty, maybe we should show all?
|
||||
// User asked to RESTRICT. So if provided, WE RESTRICT.
|
||||
const isAssigned = !assignedProfessionals || assignedProfessionals.includes(p.id);
|
||||
// Filter 2: Must not be already in this car
|
||||
const isInCar = car.passengers.some((pass: any) => pass.profissional_id === p.id);
|
||||
return isAssigned && !isInCar;
|
||||
})
|
||||
.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.nomeEventos || p.nome}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventLogistics;
|
||||
279
frontend/components/EventScheduler.tsx
Normal file
279
frontend/components/EventScheduler.tsx
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Plus, Trash, User, Truck, MapPin } from "lucide-react";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { listEscalas, createEscala, deleteEscala, EscalaInput } from "../services/apiService";
|
||||
import { useData } from "../contexts/DataContext";
|
||||
import { UserRole } from "../types";
|
||||
|
||||
interface EventSchedulerProps {
|
||||
agendaId: string;
|
||||
dataEvento: string; // YYYY-MM-DD
|
||||
allowedProfessionals?: string[]; // IDs of professionals allowed to be scheduled
|
||||
onUpdateStats?: (stats: { studios: number }) => void;
|
||||
defaultTime?: string;
|
||||
}
|
||||
|
||||
const timeSlots = [
|
||||
"07:00", "08:00", "09:00", "10:00", "11:00", "12:00",
|
||||
"13:00", "14:00", "15:00", "16:00", "17:00", "18:00",
|
||||
"19:00", "20:00", "21:00", "22:00", "23:00", "00:00"
|
||||
];
|
||||
|
||||
const EventScheduler: React.FC<EventSchedulerProps> = ({ agendaId, dataEvento, allowedProfessionals, onUpdateStats, defaultTime }) => {
|
||||
const { token, user } = useAuth();
|
||||
const { professionals, events } = useData();
|
||||
const [escalas, setEscalas] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// New entry state
|
||||
const [selectedProf, setSelectedProf] = useState("");
|
||||
const [startTime, setStartTime] = useState(defaultTime || "08:00");
|
||||
const [endTime, setEndTime] = useState("12:00"); // Could calculated based on start, but keep simple
|
||||
const [role, setRole] = useState("");
|
||||
|
||||
const isEditable = user?.role === UserRole.SUPERADMIN || user?.role === UserRole.BUSINESS_OWNER;
|
||||
|
||||
// Helper to check availability
|
||||
const checkAvailability = (profId: string) => {
|
||||
// Parse current request time in minutes
|
||||
const [h, m] = startTime.split(':').map(Number);
|
||||
const reqStart = h * 60 + m;
|
||||
const reqEnd = reqStart + 240; // +4 hours
|
||||
|
||||
// Check if professional is in any other event on the same day with overlap
|
||||
const conflict = events.some(e => {
|
||||
if (e.id === agendaId) return false; // Ignore current event (allow re-scheduling or moving within same event?)
|
||||
// Actually usually we don't want to double book in same event either unless intention is specific.
|
||||
// But 'escalas' check (Line 115) already handles "already in this scale".
|
||||
// If they are assigned to the *Event Team* (Logistics) but not Scale yet, it doesn't mean they are busy at THIS exact time?
|
||||
// Wait, 'events.photographerIds' means they are on the Team.
|
||||
// Being on the Team for specific time?
|
||||
// Does 'EventData' have time? Yes. 'e.time'.
|
||||
// If they are in another Event Team, we assume they are busy for that Event's duration.
|
||||
|
||||
if (e.date === dataEvento && e.photographerIds.includes(profId)) {
|
||||
const [eh, em] = (e.time || "00:00").split(':').map(Number);
|
||||
const evtStart = eh * 60 + em;
|
||||
const evtEnd = evtStart + 240; // Assume 4h duration for other events too
|
||||
|
||||
// Overlap check
|
||||
return (reqStart < evtEnd && evtStart < reqEnd);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return conflict;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (agendaId && token) {
|
||||
fetchEscalas();
|
||||
}
|
||||
}, [agendaId, token]);
|
||||
|
||||
// Recalculate stats whenever scales change
|
||||
useEffect(() => {
|
||||
if (onUpdateStats && escalas.length >= 0) {
|
||||
let totalStudios = 0;
|
||||
escalas.forEach(item => {
|
||||
const prof = professionals.find(p => p.id === item.profissional_id);
|
||||
if (prof && prof.qtd_estudio) {
|
||||
totalStudios += prof.qtd_estudio;
|
||||
}
|
||||
});
|
||||
onUpdateStats({ studios: totalStudios });
|
||||
}
|
||||
}, [escalas, professionals, onUpdateStats]);
|
||||
|
||||
const fetchEscalas = async () => {
|
||||
setLoading(true);
|
||||
const result = await listEscalas(agendaId, token!);
|
||||
if (result.data) {
|
||||
setEscalas(result.data);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleAddEscala = async () => {
|
||||
if (!selectedProf || !startTime) return;
|
||||
|
||||
// Create Date objects from Local Time
|
||||
const localStart = new Date(`${dataEvento}T${startTime}:00`);
|
||||
const localEnd = new Date(localStart);
|
||||
localEnd.setHours(localEnd.getHours() + 4);
|
||||
|
||||
// Convert to UTC ISO String and format for backend (Space, no ms)
|
||||
// toISOString returns YYYY-MM-DDTHH:mm:ss.sssZ
|
||||
const startISO = localStart.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, 'Z');
|
||||
const endISO = localEnd.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, 'Z');
|
||||
|
||||
const input: EscalaInput = {
|
||||
agenda_id: agendaId,
|
||||
profissional_id: selectedProf,
|
||||
data_hora_inicio: startISO,
|
||||
data_hora_fim: endISO,
|
||||
funcao_especifica: role
|
||||
};
|
||||
|
||||
const res = await createEscala(input, token!);
|
||||
if (res.data) {
|
||||
fetchEscalas();
|
||||
setSelectedProf("");
|
||||
setRole("");
|
||||
} else {
|
||||
alert("Erro ao criar escala: " + res.error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (confirm("Remover profissional da escala?")) {
|
||||
await deleteEscala(id, token!);
|
||||
fetchEscalas();
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Start with all professionals or just the allowed ones
|
||||
let availableProfs = professionals;
|
||||
if (allowedProfessionals && allowedProfessionals.length > 0) {
|
||||
availableProfs = availableProfs.filter(p => allowedProfessionals.includes(p.id));
|
||||
}
|
||||
|
||||
// 2. Filter out professionals already in schedule to prevent duplicates
|
||||
// But keep the currently selected one valid if it was just selected
|
||||
availableProfs = availableProfs.filter(p => !escalas.some(e => e.profissional_id === p.id));
|
||||
|
||||
const selectedProfessionalData = professionals.find(p => p.id === selectedProf);
|
||||
|
||||
return (
|
||||
<div className="bg-white p-4 rounded-lg shadow space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-800 flex items-center">
|
||||
<MapPin className="w-5 h-5 mr-2 text-indigo-500" />
|
||||
Escala de Profissionais
|
||||
</h3>
|
||||
|
||||
{/* Warning if restricting and empty */}
|
||||
{isEditable && allowedProfessionals && allowedProfessionals.length === 0 && (
|
||||
<div className="bg-yellow-50 text-yellow-800 text-sm p-3 rounded border border-yellow-200">
|
||||
Nenhum profissional atribuído a este evento. Adicione membros à equipe antes de criar a escala.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Form - Only for Admins */}
|
||||
{isEditable && (
|
||||
<div className="bg-gray-50 p-3 rounded-md space-y-3">
|
||||
<div className="flex flex-wrap gap-2 items-end">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<label className="text-xs text-gray-500">Profissional</label>
|
||||
<select
|
||||
className="w-full p-2 rounded border bg-white"
|
||||
value={selectedProf}
|
||||
onChange={(e) => setSelectedProf(e.target.value)}
|
||||
>
|
||||
<option value="">Selecione...</option>
|
||||
{availableProfs.map(p => {
|
||||
const isBusy = checkAvailability(p.id);
|
||||
return (
|
||||
<option key={p.id} value={p.id} disabled={isBusy} className={isBusy ? "text-gray-400" : ""}>
|
||||
{p.nome} - {p.role || "Profissional"} {isBusy ? "(Ocupado em outro evento)" : ""}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
<div className="w-24">
|
||||
<label className="text-xs text-gray-500">Início</label>
|
||||
<input
|
||||
type="time"
|
||||
className="w-full p-2 rounded border bg-white"
|
||||
value={startTime}
|
||||
onChange={(e) => setStartTime(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-[150px]">
|
||||
<label className="text-xs text-gray-500">Função (Opcional)</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Ex: Palco"
|
||||
className="w-full p-2 rounded border bg-white"
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddEscala}
|
||||
className="bg-green-600 hover:bg-green-700 text-white p-2 rounded flex items-center"
|
||||
>
|
||||
<Plus size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Equipment Info Preview */}
|
||||
{selectedProfessionalData && (selectedProfessionalData.equipamentos || selectedProfessionalData.qtd_estudio > 0) && (
|
||||
<div className="text-xs text-gray-500 bg-white p-2 rounded border border-dashed border-gray-300">
|
||||
<span className="font-semibold">Equipamentos:</span> {selectedProfessionalData.equipamentos || "Nenhum cadastrado"}
|
||||
{selectedProfessionalData.qtd_estudio > 0 && (
|
||||
<span className="ml-3 text-indigo-600 font-semibold">• Possui {selectedProfessionalData.qtd_estudio} Estúdio(s)</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeline / List */}
|
||||
<div className="space-y-2">
|
||||
{loading ? <p>Carregando...</p> : escalas.length === 0 ? (
|
||||
<p className="text-gray-500 text-sm italic">Nenhuma escala definida.</p>
|
||||
) : (
|
||||
escalas.map(item => {
|
||||
// Find professional data again to show equipment in list if needed
|
||||
// Ideally backend should return it, but for now we look up in global list if available
|
||||
const profData = professionals.find(p => p.id === item.profissional_id);
|
||||
|
||||
return (
|
||||
<div key={item.id} className="flex flex-col p-2 hover:bg-gray-50 rounded border-b">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-gray-200 overflow-hidden flex-shrink-0">
|
||||
{item.avatar_url ? (
|
||||
<img src={item.avatar_url} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<User className="w-6 h-6 m-2 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800">
|
||||
{item.profissional_nome}
|
||||
{item.phone && <span className="ml-2 text-xs text-gray-500 font-normal">({item.phone})</span>}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{new Date(item.start).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} -
|
||||
{new Date(item.end).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
{item.role && <span className="ml-2 bg-blue-100 text-blue-800 px-1 rounded text-[10px]">{item.role}</span>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{isEditable && (
|
||||
<button
|
||||
onClick={() => handleDelete(item.id)}
|
||||
className="text-red-500 hover:text-red-700 p-1"
|
||||
>
|
||||
<Trash size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Show equipment if available */}
|
||||
{profData && profData.equipamentos && (
|
||||
<div className="ml-14 mt-1 text-[10px] text-gray-400">
|
||||
<span className="font-bold">Equip:</span> {profData.equipamentos}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventScheduler;
|
||||
|
|
@ -3,27 +3,67 @@ import { Professional } from '../types';
|
|||
import { Button } from './Button';
|
||||
import {
|
||||
X, Mail, Phone, MapPin, Building, Star, Camera, DollarSign, Award,
|
||||
User, Car, CreditCard, AlertTriangle
|
||||
User, Car, CreditCard, AlertTriangle, Calendar, Clock, Edit2
|
||||
} from 'lucide-react';
|
||||
import { getAgendas } from '../services/apiService';
|
||||
|
||||
interface ProfessionalDetailsModalProps {
|
||||
professional: Professional;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onEdit?: () => void;
|
||||
}
|
||||
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { UserRole } from '../types';
|
||||
|
||||
// ... (imports remain)
|
||||
|
||||
export const ProfessionalDetailsModal: React.FC<ProfessionalDetailsModalProps> = ({
|
||||
professional,
|
||||
isOpen,
|
||||
onClose,
|
||||
onEdit
|
||||
}) => {
|
||||
const { user, token } = useAuth();
|
||||
const [assignedEvents, setAssignedEvents] = React.useState<any[]>([]);
|
||||
const [loadingEvents, setLoadingEvents] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isOpen && user && token && (user.role === UserRole.SUPERADMIN || user.role === UserRole.BUSINESS_OWNER || user.id === professional.usuarioId)) {
|
||||
fetchAssignments();
|
||||
}
|
||||
}, [isOpen, user, token, professional.id]);
|
||||
|
||||
const fetchAssignments = async () => {
|
||||
setLoadingEvents(true);
|
||||
const res = await getAgendas(token!);
|
||||
if (res.data) {
|
||||
// Filter events where professional is assigned
|
||||
const filtered = res.data.filter((evt: any) =>
|
||||
evt.assigned_professionals?.some((ap: any) => ap.professional_id === professional.id)
|
||||
);
|
||||
// Sort by date desc
|
||||
filtered.sort((a: any, b: any) => new Date(b.data_evento).getTime() - new Date(a.data_evento).getTime());
|
||||
setAssignedEvents(filtered);
|
||||
}
|
||||
setLoadingEvents(false);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const canViewDetails =
|
||||
user?.role === UserRole.SUPERADMIN ||
|
||||
user?.role === UserRole.BUSINESS_OWNER ||
|
||||
(user?.id && professional.usuarioId && user.id === professional.usuarioId);
|
||||
|
||||
// Also check legacy/fallback logic if needed, but primary is role or ownership
|
||||
const isAdminOrOwner = canViewDetails; // Keeping variable name for now or refactoring below -> refactoring below to use canViewDetails for clarity is better but to minimize diff noise we can keep it or rename it. Let's rename it to avoid confusion.
|
||||
|
||||
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-4xl w-full max-h-[90vh] overflow-y-auto flex flex-col relative animate-slideIn">
|
||||
|
||||
{/* Header com Capa/Avatar Style */}
|
||||
{/* Header... (remains same) */}
|
||||
<div className="h-32 bg-gradient-to-r from-brand-purple to-brand-purple/80 relative shrink-0">
|
||||
<button
|
||||
onClick={onClose}
|
||||
|
|
@ -33,10 +73,8 @@ export const ProfessionalDetailsModal: React.FC<ProfessionalDetailsModalProps> =
|
|||
</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 */}
|
||||
{/* Avatar... (remains same) */}
|
||||
<div
|
||||
className="w-32 h-32 rounded-full border-4 border-white bg-white shadow-lg mb-4 bg-gray-200"
|
||||
style={{
|
||||
|
|
@ -53,7 +91,8 @@ export const ProfessionalDetailsModal: React.FC<ProfessionalDetailsModalProps> =
|
|||
<User size={14} />
|
||||
{professional.role || "Profissional"}
|
||||
</span>
|
||||
{professional.media !== undefined && professional.media !== null && (
|
||||
{/* Performance Rating - Only for Admins */}
|
||||
{isAdminOrOwner && professional.media !== undefined && professional.media !== null && (
|
||||
<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" />
|
||||
{typeof professional.media === 'number' ? professional.media.toFixed(1) : parseFloat(String(professional.media)).toFixed(1)}
|
||||
|
|
@ -63,43 +102,49 @@ export const ProfessionalDetailsModal: React.FC<ProfessionalDetailsModalProps> =
|
|||
</div>
|
||||
|
||||
<div className="w-full grid grid-cols-1 md:grid-cols-2 gap-8 mt-8">
|
||||
|
||||
{/* Dados Pessoais */}
|
||||
{/* Dados Pessoais - Protected */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-bold text-gray-900 flex items-center gap-2 mb-4 border-b pb-2">
|
||||
<User size={20} className="text-brand-gold" />
|
||||
Dados Pessoais
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4 text-sm">
|
||||
{professional.email && (
|
||||
<div className="flex items-start gap-3 text-gray-600">
|
||||
<Mail size={18} className="mt-1 shrink-0 text-gray-400" />
|
||||
<span className="break-all">{professional.email}</span>
|
||||
</div>
|
||||
)}
|
||||
{professional.whatsapp && (
|
||||
<div className="flex items-start gap-3 text-gray-600">
|
||||
<Phone size={18} className="mt-1 shrink-0 text-gray-400" />
|
||||
<span>{professional.whatsapp}</span>
|
||||
</div>
|
||||
)}
|
||||
{(professional.cidade || professional.uf) && (
|
||||
<div className="flex items-start gap-3 text-gray-600">
|
||||
<MapPin size={18} className="mt-1 shrink-0 text-gray-400" />
|
||||
<span>{professional.cidade}{professional.cidade && professional.uf ? ", " : ""}{professional.uf}</span>
|
||||
</div>
|
||||
)}
|
||||
{professional.endereco && (
|
||||
<div className="flex items-start gap-3 text-gray-600">
|
||||
<Building size={18} className="mt-1 shrink-0 text-gray-400" />
|
||||
<span className="text-sm">{professional.endereco}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isAdminOrOwner ? (
|
||||
<div className="space-y-4 text-sm">
|
||||
{professional.email && (
|
||||
<div className="flex items-start gap-3 text-gray-600">
|
||||
<Mail size={18} className="mt-1 shrink-0 text-gray-400" />
|
||||
<span className="break-all">{professional.email}</span>
|
||||
</div>
|
||||
)}
|
||||
{professional.whatsapp && (
|
||||
<div className="flex items-start gap-3 text-gray-600">
|
||||
<Phone size={18} className="mt-1 shrink-0 text-gray-400" />
|
||||
<span>{professional.whatsapp}</span>
|
||||
</div>
|
||||
)}
|
||||
{(professional.cidade || professional.uf) && (
|
||||
<div className="flex items-start gap-3 text-gray-600">
|
||||
<MapPin size={18} className="mt-1 shrink-0 text-gray-400" />
|
||||
<span>{professional.cidade}{professional.cidade && professional.uf ? ", " : ""}{professional.uf}</span>
|
||||
</div>
|
||||
)}
|
||||
{professional.endereco && (
|
||||
<div className="flex items-start gap-3 text-gray-600">
|
||||
<Building size={18} className="mt-1 shrink-0 text-gray-400" />
|
||||
<span className="text-sm">{professional.endereco}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 bg-gray-50 rounded-lg border border-gray-100 text-center">
|
||||
<AlertTriangle className="w-8 h-8 text-gray-400 mx-auto mb-2" />
|
||||
<p className="text-gray-500 text-sm">Informações de contato restritas.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Equipamentos */}
|
||||
{/* Equipamentos - Public */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-bold text-gray-900 flex items-center gap-2 mb-4 border-b pb-2">
|
||||
<Camera size={20} className="text-brand-gold" />
|
||||
|
|
@ -130,82 +175,129 @@ export const ProfessionalDetailsModal: React.FC<ProfessionalDetailsModalProps> =
|
|||
|
||||
</div>
|
||||
|
||||
{/* Dados Financeiros */}
|
||||
<div className="w-full mt-8">
|
||||
<h3 className="text-lg font-bold text-gray-900 flex items-center gap-2 mb-4 border-b pb-2">
|
||||
<DollarSign size={20} className="text-brand-gold" />
|
||||
Dados Financeiros
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm w-full">
|
||||
<div className="bg-gray-50 p-3 rounded-lg border border-gray-100">
|
||||
<span className="block text-gray-500 text-xs uppercase tracking-wider font-semibold mb-1">CPF/CNPJ Titular</span>
|
||||
<span className="font-medium text-gray-900">{professional.cpf_cnpj_titular || "-"}</span>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-3 rounded-lg border border-gray-100">
|
||||
<span className="block text-gray-500 text-xs uppercase tracking-wider font-semibold mb-1">Chave Pix</span>
|
||||
<span className="font-medium text-gray-900">{professional.conta_pix || "-"}</span>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-3 rounded-lg border border-gray-100">
|
||||
<span className="block text-gray-500 text-xs uppercase tracking-wider font-semibold mb-1">Banco / Agência</span>
|
||||
<span className="font-medium text-gray-900">
|
||||
{professional.banco || "-"}{professional.agencia ? ` / ${professional.agencia}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-3 rounded-lg border border-gray-100">
|
||||
<span className="block text-gray-500 text-xs uppercase tracking-wider font-semibold mb-1">Tabela Free</span>
|
||||
<span className="font-medium text-gray-900">R$ {professional.tabela_free || "0,00"}</span>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-3 rounded-lg border border-gray-100">
|
||||
<span className="block text-gray-500 text-xs uppercase tracking-wider font-semibold mb-1">Tipo de Cartão</span>
|
||||
<span className="font-medium text-gray-900">{professional.tipo_cartao || "-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Performance / Observations */}
|
||||
<div className="w-full mt-8 bg-brand-gold/5 rounded-xl p-6 border border-brand-gold/10">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-2 bg-white rounded-full text-brand-gold shadow-sm">
|
||||
<Star size={24} className="text-brand-gold fill-brand-gold" />
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<h4 className="text-lg font-bold text-gray-900 mb-4">Performance & Avaliação</h4>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||
<div className="text-center bg-white p-2 rounded shadow-sm">
|
||||
<div className="text-xs text-gray-500 mb-1">Técnica</div>
|
||||
<div className="font-bold text-lg text-gray-800">{professional.qual_tec || 0}</div>
|
||||
{/* Dados Financeiros & Performance - Protected */}
|
||||
{isAdminOrOwner && (
|
||||
<>
|
||||
<div className="w-full mt-8">
|
||||
<h3 className="text-lg font-bold text-gray-900 flex items-center gap-2 mb-4 border-b pb-2">
|
||||
<DollarSign size={20} className="text-brand-gold" />
|
||||
Dados Financeiros
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm w-full">
|
||||
<div className="bg-gray-50 p-3 rounded-lg border border-gray-100">
|
||||
<span className="block text-gray-500 text-xs uppercase tracking-wider font-semibold mb-1">CPF/CNPJ Titular</span>
|
||||
<span className="font-medium text-gray-900">{professional.cpf_cnpj_titular || "-"}</span>
|
||||
</div>
|
||||
<div className="text-center bg-white p-2 rounded shadow-sm">
|
||||
<div className="text-xs text-gray-500 mb-1">Simpatia</div>
|
||||
<div className="font-bold text-lg text-gray-800">{professional.educacao_simpatia || 0}</div>
|
||||
<div className="bg-gray-50 p-3 rounded-lg border border-gray-100">
|
||||
<span className="block text-gray-500 text-xs uppercase tracking-wider font-semibold mb-1">Chave Pix</span>
|
||||
<span className="font-medium text-gray-900">{professional.conta_pix || "-"}</span>
|
||||
</div>
|
||||
<div className="text-center bg-white p-2 rounded shadow-sm">
|
||||
<div className="text-xs text-gray-500 mb-1">Desempenho</div>
|
||||
<div className="font-bold text-lg text-gray-800">{professional.desempenho_evento || 0}</div>
|
||||
<div className="bg-gray-50 p-3 rounded-lg border border-gray-100">
|
||||
<span className="block text-gray-500 text-xs uppercase tracking-wider font-semibold mb-1">Banco / Agência</span>
|
||||
<span className="font-medium text-gray-900">
|
||||
{professional.banco || "-"}{professional.agencia ? ` / ${professional.agencia}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-center bg-white p-2 rounded shadow-sm">
|
||||
<div className="text-xs text-gray-500 mb-1">Horário</div>
|
||||
<div className="font-bold text-lg text-gray-800">{professional.disp_horario || 0}</div>
|
||||
<div className="bg-gray-50 p-3 rounded-lg border border-gray-100">
|
||||
<span className="block text-gray-500 text-xs uppercase tracking-wider font-semibold mb-1">Tabela Free</span>
|
||||
<span className="font-medium text-gray-900">R$ {professional.tabela_free || "0,00"}</span>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-3 rounded-lg border border-gray-100">
|
||||
<span className="block text-gray-500 text-xs uppercase tracking-wider font-semibold mb-1">Tipo de Cartão</span>
|
||||
<span className="font-medium text-gray-900">{professional.tipo_cartao || "-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="text-gray-600 text-sm leading-relaxed mb-2">
|
||||
Média Geral: <strong>{professional.media ? (typeof professional.media === 'number' ? professional.media.toFixed(1) : parseFloat(String(professional.media)).toFixed(1)) : "N/A"}</strong>
|
||||
</p>
|
||||
{professional.observacao && (
|
||||
<div className="mt-3 text-sm text-gray-500 italic border-t border-brand-gold/10 pt-2">
|
||||
"{professional.observacao}"
|
||||
{/* Performance / Observations - Protected */}
|
||||
{isAdminOrOwner && (
|
||||
<div className="w-full mt-8 bg-brand-gold/5 rounded-xl p-6 border border-brand-gold/10">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-2 bg-white rounded-full text-brand-gold shadow-sm">
|
||||
<Star size={24} className="text-brand-gold fill-brand-gold" />
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<h4 className="text-lg font-bold text-gray-900 mb-4">Performance & Avaliação</h4>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||
<div className="text-center bg-white p-2 rounded shadow-sm">
|
||||
<div className="text-xs text-gray-500 mb-1">Técnica</div>
|
||||
<div className="font-bold text-lg text-gray-800">{professional.qual_tec || 0}</div>
|
||||
</div>
|
||||
<div className="text-center bg-white p-2 rounded shadow-sm">
|
||||
<div className="text-xs text-gray-500 mb-1">Simpatia</div>
|
||||
<div className="font-bold text-lg text-gray-800">{professional.educacao_simpatia || 0}</div>
|
||||
</div>
|
||||
<div className="text-center bg-white p-2 rounded shadow-sm">
|
||||
<div className="text-xs text-gray-500 mb-1">Desempenho</div>
|
||||
<div className="font-bold text-lg text-gray-800">{professional.desempenho_evento || 0}</div>
|
||||
</div>
|
||||
<div className="text-center bg-white p-2 rounded shadow-sm">
|
||||
<div className="text-xs text-gray-500 mb-1">Horário</div>
|
||||
<div className="font-bold text-lg text-gray-800">{professional.disp_horario || 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-gray-600 text-sm leading-relaxed mb-2">
|
||||
Média Geral: <strong>{professional.media ? (typeof professional.media === 'number' ? professional.media.toFixed(1) : parseFloat(String(professional.media)).toFixed(1)) : "N/A"}</strong>
|
||||
</p>
|
||||
{professional.observacao && (
|
||||
<div className="mt-3 text-sm text-gray-500 italic border-t border-brand-gold/10 pt-2">
|
||||
"{professional.observacao}"
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-full mt-8 flex justify-end">
|
||||
{/* Assignments - Protected */}
|
||||
{canViewDetails && (
|
||||
<div className="w-full mt-8">
|
||||
<h3 className="text-lg font-bold text-gray-900 flex items-center gap-2 mb-4 border-b pb-2">
|
||||
<Calendar size={20} className="text-brand-gold" />
|
||||
Eventos Atribuídos
|
||||
</h3>
|
||||
{loadingEvents ? (
|
||||
<p className="text-sm text-gray-500">Carregando agenda...</p>
|
||||
) : assignedEvents.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 italic">Nenhum evento atribuído.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{assignedEvents.map((evt: any) => (
|
||||
<div key={evt.id} className="bg-white border rounded-lg p-3 flex justify-between items-center shadow-sm">
|
||||
<div>
|
||||
<p className="font-bold text-gray-800 text-sm">{evt.empresa_nome || "Empresa"} - {evt.tipo_evento_nome || "Evento"}</p>
|
||||
<div className="flex gap-3 text-xs text-gray-500 mt-1">
|
||||
<span className="flex items-center gap-1"><Calendar size={12} /> {new Date(evt.data_evento).toLocaleDateString()}</span>
|
||||
<span className="flex items-center gap-1"><Clock size={12} /> {evt.horario}</span>
|
||||
{evt.local_evento && <span className="flex items-center gap-1"><MapPin size={12} /> {evt.local_evento}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className={`text-xs px-2 py-1 rounded-full ${evt.status === 'Confirmado' ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'}`}>
|
||||
{evt.status || "Agendado"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-full mt-8 flex justify-end gap-3">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Fechar
|
||||
</Button>
|
||||
{onEdit && (
|
||||
<Button onClick={onEdit}>
|
||||
<Edit2 size={16} className="mr-2" />
|
||||
Editar Dados
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { UserRole, EventData, EventStatus, EventType, Professional } from "../types";
|
||||
import { EventTable } from "../components/EventTable";
|
||||
import { EventFiltersBar, EventFilters } from "../components/EventFiltersBar";
|
||||
|
|
@ -32,6 +33,7 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
|||
initialView = "list",
|
||||
}) => {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
events,
|
||||
getEventsByRole,
|
||||
|
|
@ -396,8 +398,15 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
|||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{/* Actions Toolbar */}
|
||||
<div className="flex flex-wrap gap-2 mb-6 pb-4 border-b">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => navigate(`/agenda/${selectedEvent.id}`)}
|
||||
className="text-sm bg-brand-purple text-white hover:bg-brand-purple/90"
|
||||
>
|
||||
<CheckCircle size={16} className="mr-2" /> Área Operacional
|
||||
</Button>
|
||||
|
||||
{(user.role === UserRole.BUSINESS_OWNER ||
|
||||
user.role === UserRole.SUPERADMIN) && (
|
||||
<>
|
||||
|
|
@ -434,6 +443,24 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
|||
>
|
||||
<Map size={16} className="mr-2" /> Abrir no Maps
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
// Use a direct window location or navigate if available.
|
||||
// Since Dashboard doesn't seem to expose navigate cleanly, likely we need to pass it or use window.location
|
||||
// Actually, Dashboard is usually wrapped in a Router context.
|
||||
// We can use window.open or window.location.href for now as Dashboard props don't include navigation function easily accessible without hooking.
|
||||
// However, line 348 uses setView("list"). Ideally we use useNavigate.
|
||||
// Looking at lines 34-45, we don't have navigate.
|
||||
// But Dashboard is inside PageWrapper which has navigate? No.
|
||||
// Let's use window.location.assign for simplicity or add useNavigate.
|
||||
window.location.assign(`/agenda/${selectedEvent.id}`);
|
||||
}}
|
||||
className="text-sm bg-brand-purple text-white hover:bg-brand-purple/90"
|
||||
>
|
||||
<CheckCircle size={16} className="mr-2" /> Área Operacional
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
|
|
@ -878,8 +905,14 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
|||
);
|
||||
|
||||
const status = assignment ? assignment.status : null;
|
||||
const isAssigned = !!status && status !== "REJEITADO"; // Consider assigned if not rejected (effectively)
|
||||
const isAvailable = true;
|
||||
const isAssigned = !!status && status !== "REJEITADO";
|
||||
|
||||
// Check if busy in other events on the same date
|
||||
const isBusy = !isAssigned && events.some(e =>
|
||||
e.id !== selectedEvent.id &&
|
||||
e.date === selectedEvent.date &&
|
||||
e.photographerIds.includes(photographer.id)
|
||||
);
|
||||
|
||||
return (
|
||||
<tr
|
||||
|
|
@ -940,9 +973,9 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
|||
</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 className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium ${isBusy ? "bg-red-100 text-red-800" : "bg-gray-100 text-gray-600"}`}>
|
||||
{isBusy ? <UserX size={14} /> : <UserCheck size={14} />}
|
||||
{isBusy ? "Em outro evento" : "Disponível"}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
|
|
@ -953,7 +986,7 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
|||
onClick={() =>
|
||||
togglePhotographer(photographer.id)
|
||||
}
|
||||
disabled={false}
|
||||
disabled={!status && isBusy}
|
||||
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"
|
||||
: "bg-brand-gold text-white hover:bg-[#a5bd2e]"
|
||||
|
|
@ -993,6 +1026,13 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
|||
);
|
||||
const status = assignment ? assignment.status : null;
|
||||
|
||||
// Check if busy in other events on the same date
|
||||
const isBusy = !status && events.some(e =>
|
||||
e.id !== selectedEvent.id &&
|
||||
e.date === selectedEvent.date &&
|
||||
e.photographerIds.includes(photographer.id)
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={photographer.id} className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm">
|
||||
<div className="flex items-center gap-3 mb-3" onClick={() => handleViewProfessional(photographer)}>
|
||||
|
|
@ -1030,8 +1070,9 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
|||
</span>
|
||||
)}
|
||||
{!status && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-gray-100 text-gray-600 rounded-full text-xs font-medium">
|
||||
<UserCheck size={12} /> Disponível
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${isBusy ? "bg-red-100 text-red-800" : "bg-gray-100 text-gray-600"}`}>
|
||||
{isBusy ? <UserX size={12} /> : <UserCheck size={12} />}
|
||||
{isBusy ? "Em outro evento" : "Disponível"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1045,12 +1086,13 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
|||
</button>
|
||||
<button
|
||||
onClick={() => togglePhotographer(photographer.id)}
|
||||
disabled={!status && isBusy}
|
||||
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-colors ${status === "ACEITO" || status === "PENDENTE"
|
||||
? "bg-red-100 text-red-700 hover:bg-red-200"
|
||||
: "bg-brand-gold text-white hover:bg-[#a5bd2e]"
|
||||
: isBusy ? "bg-gray-300 text-gray-500 cursor-not-allowed" : "bg-brand-gold text-white hover:bg-[#a5bd2e]"
|
||||
}`}
|
||||
>
|
||||
{status === "ACEITO" || status === "PENDENTE" ? "Remover" : "Adicionar"}
|
||||
{status === "ACEITO" || status === "PENDENTE" ? "Remover" : isBusy ? "Ocupado" : "Adicionar"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
159
frontend/pages/EventDetails.tsx
Normal file
159
frontend/pages/EventDetails.tsx
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, MapPin, Calendar, Clock, DollarSign } from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { getAgendas } from '../services/apiService';
|
||||
import EventScheduler from '../components/EventScheduler';
|
||||
import EventLogistics from '../components/EventLogistics';
|
||||
|
||||
const EventDetails: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { token } = useAuth();
|
||||
const [event, setEvent] = useState<any | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [calculatedStats, setCalculatedStats] = useState({ studios: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
if (id && token) {
|
||||
loadEvent();
|
||||
}
|
||||
}, [id, token]);
|
||||
|
||||
const loadEvent = async () => {
|
||||
// Since we don't have a getEventById, we list and filter for now (MVP).
|
||||
// Ideally backend should have GET /agenda/:id
|
||||
const res = await getAgendas(token!);
|
||||
if (res.data) {
|
||||
const found = res.data.find((e: any) => e.id === id);
|
||||
setEvent(found);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
if (loading) return <div className="p-8 text-center">Carregando detalhes do evento...</div>;
|
||||
if (!event) return <div className="p-8 text-center text-red-500">Evento não encontrado.</div>;
|
||||
|
||||
const formattedDate = new Date(event.data_evento).toLocaleDateString();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-6">
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<button onClick={() => navigate('/eventos')} className="p-2 hover:bg-gray-200 rounded-full transition-colors">
|
||||
<ArrowLeft className="w-6 h-6 text-gray-600" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-800 flex items-center gap-2">
|
||||
{event.empresa_nome} - {event.tipo_evento_nome}
|
||||
<span className={`text-xs px-2 py-1 rounded-full ${event.status === 'Confirmado' ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'}`}>
|
||||
{event.status}
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500">ID: {event.fot_id}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Event Info Card (Spreadsheet Header Style) */}
|
||||
<div className="bg-white rounded-lg shadow p-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 border-l-4 border-brand-purple">
|
||||
<div className="flex items-start gap-3">
|
||||
<Calendar className="w-5 h-5 text-brand-purple mt-1" />
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 uppercase font-bold">Data</p>
|
||||
<p className="font-medium text-gray-800">{formattedDate}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<MapPin className="w-5 h-5 text-brand-purple mt-1" />
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 uppercase font-bold">Local:</p>
|
||||
{(() => {
|
||||
const localVal = event['local_evento'] || event.local || event.local_evento;
|
||||
const isUrl = localVal && String(localVal).startsWith('http');
|
||||
|
||||
if (isUrl) {
|
||||
return (
|
||||
<a
|
||||
href={localVal}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium text-brand-purple hover:underline truncate block"
|
||||
title="Abrir no Mapa"
|
||||
>
|
||||
{event.locationName || "Ver Localização no Mapa"}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return <p className="font-medium text-gray-800">{localVal || "Não informado"}</p>;
|
||||
})()}
|
||||
<p className="text-xs text-gray-500">{event.endereco}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<Clock className="w-5 h-5 text-brand-purple mt-1" />
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 uppercase font-bold">Horário</p>
|
||||
<p className="font-medium text-gray-800">{event.horario}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-5 h-5 text-brand-purple mt-1 font-bold text-xs border border-brand-purple rounded flex items-center justify-center">?</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 uppercase font-bold">Observações</p>
|
||||
<p className="text-xs text-gray-600 line-clamp-2">{event.observacoes_evento || "Nenhuma observação."}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content: Scheduling & Logistics */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Left: Scheduling (Escala) */}
|
||||
<EventScheduler
|
||||
agendaId={id!}
|
||||
dataEvento={event.data_evento.split('T')[0]}
|
||||
allowedProfessionals={(event as any).assigned_professionals?.map((p: any) => p.professional_id)}
|
||||
onUpdateStats={setCalculatedStats}
|
||||
defaultTime={event.horario}
|
||||
/>
|
||||
|
||||
{/* Right: Logistics (Carros) */}
|
||||
<div className="space-y-6">
|
||||
<EventLogistics
|
||||
agendaId={id!}
|
||||
assignedProfessionals={(event as any).assigned_professionals?.map((p: any) => p.professional_id)}
|
||||
/>
|
||||
|
||||
{/* Equipment / Studios Section (Placeholder for now based on spreadsheet) */}
|
||||
<div className="bg-white p-4 rounded-lg shadow">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-2 flex items-center">
|
||||
<DollarSign className="w-5 h-5 mr-2 text-green-600" /> {/* Using DollarSign as generic icon for assets/inventory for now, map to Camera later */}
|
||||
Equipamentos & Estúdios
|
||||
</h3>
|
||||
<div className="bg-gray-50 p-3 rounded text-sm space-y-2">
|
||||
<div className="flex justify-between border-b pb-2">
|
||||
<span className="text-gray-600">Qtd. Estúdios (Automático):</span>
|
||||
<span className="font-bold text-indigo-600">{calculatedStats.studios}</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-b pb-2">
|
||||
<span className="text-gray-600">Ponto de Foto:</span>
|
||||
<span className="font-bold">{event.qtd_ponto_foto || 0}</span>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<p className="text-xs text-gray-500 font-bold mb-1">Notas de Equipamento:</p>
|
||||
<textarea
|
||||
className="w-full text-xs p-2 rounded border bg-white"
|
||||
placeholder="Ex: Levar 2 tripés extras..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventDetails;
|
||||
|
|
@ -37,6 +37,7 @@ import {
|
|||
} from "../services/apiService";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { Professional, CreateProfessionalDTO } from "../types";
|
||||
import { ProfessionalDetailsModal } from "../components/ProfessionalDetailsModal";
|
||||
|
||||
export const TeamPage: React.FC = () => {
|
||||
const { user, token: contextToken } = useAuth();
|
||||
|
|
@ -821,205 +822,16 @@ export const TeamPage: React.FC = () => {
|
|||
|
||||
{/* View Modal */}
|
||||
{viewProfessional && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 animate-fadeIn">
|
||||
<div className="bg-white rounded-xl max-w-2xl w-full p-0 overflow-hidden shadow-2xl max-h-[90vh] overflow-y-auto animate-slideIn">
|
||||
{/* Header / Avatar Section */}
|
||||
<div className="relative pt-12 pb-6 px-8 text-center bg-gradient-to-b from-gray-50 to-white">
|
||||
<button
|
||||
onClick={() => setViewProfessional(null)}
|
||||
className="absolute top-4 right-4 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
|
||||
<div className="relative inline-block mb-4">
|
||||
<div className="w-32 h-32 rounded-full p-[3px] bg-gradient-to-tr from-brand-gold to-purple-600">
|
||||
<img
|
||||
className="w-full h-full rounded-full object-cover border-4 border-white"
|
||||
src={viewProfessional.avatar_url || viewProfessional.avatar || GenericAvatar}
|
||||
alt={viewProfessional.nome}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-3xl font-serif font-bold text-gray-900 mb-2">{viewProfessional.nome}</h2>
|
||||
|
||||
<div className="flex justify-center items-center gap-3">
|
||||
<span className="px-3 py-1 rounded-full bg-brand-gold/10 text-brand-gold-dark text-sm font-semibold border border-brand-gold/20 flex items-center gap-2">
|
||||
{(() => {
|
||||
const RoleIcon = getRoleIcon(getRoleName(viewProfessional.funcao_profissional_id));
|
||||
return <RoleIcon size={14} />;
|
||||
})()}
|
||||
{getRoleName(viewProfessional.funcao_profissional_id)}
|
||||
</span>
|
||||
{viewProfessional.media !== undefined && viewProfessional.media !== null && (
|
||||
<span className="px-3 py-1 rounded-full bg-yellow-100 text-yellow-700 text-sm font-semibold border border-yellow-200 flex items-center gap-1">
|
||||
<Star size={14} className="fill-current" />
|
||||
{typeof viewProfessional.media === 'number' ? viewProfessional.media.toFixed(1) : parseFloat(viewProfessional.media).toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{/* Personal Data */}
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900 flex items-center gap-2 mb-4 border-b pb-2">
|
||||
<User size={20} className="text-brand-gold" />
|
||||
Dados Pessoais
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{viewProfessional.email && (
|
||||
<div className="flex items-start gap-3 text-gray-600">
|
||||
<Mail size={18} className="mt-1 shrink-0 text-gray-400" />
|
||||
<span className="break-all">{viewProfessional.email}</span>
|
||||
</div>
|
||||
)}
|
||||
{viewProfessional.whatsapp && (
|
||||
<div className="flex items-start gap-3 text-gray-600">
|
||||
<Phone size={18} className="mt-1 shrink-0 text-gray-400" />
|
||||
<span>{viewProfessional.whatsapp}</span>
|
||||
</div>
|
||||
)}
|
||||
{(viewProfessional.cidade || viewProfessional.uf) && (
|
||||
<div className="flex items-start gap-3 text-gray-600">
|
||||
<MapPin size={18} className="mt-1 shrink-0 text-gray-400" />
|
||||
<span>{viewProfessional.cidade}{viewProfessional.cidade && viewProfessional.uf ? ", " : ""}{viewProfessional.uf}</span>
|
||||
</div>
|
||||
)}
|
||||
{viewProfessional.endereco && (
|
||||
<div className="flex items-start gap-3 text-gray-600">
|
||||
<Building size={18} className="mt-1 shrink-0 text-gray-400" />
|
||||
<span className="text-sm">{viewProfessional.endereco}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Equipment & Skills */}
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900 flex items-center gap-2 mb-4 border-b pb-2">
|
||||
<Camera size={20} className="text-brand-gold" />
|
||||
Equipamentos
|
||||
</h3>
|
||||
<div className="bg-gray-50 rounded-lg p-4 text-sm text-gray-700 whitespace-pre-wrap leading-relaxed border border-gray-100">
|
||||
{viewProfessional.equipamentos || "Nenhum equipamento listado."}
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{viewProfessional.carro_disponivel && (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
<Car size={12} className="mr-1" /> Carro Próprio
|
||||
</span>
|
||||
)}
|
||||
{viewProfessional.tem_estudio && (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
|
||||
<Building size={12} className="mr-1" /> Estúdio ({viewProfessional.qtd_estudio})
|
||||
</span>
|
||||
)}
|
||||
{viewProfessional.extra_por_equipamento && (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
<CreditCard size={12} className="mr-1" /> Extra p/ Equip.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Performance / Observations */}
|
||||
{/* Financial Data */}
|
||||
<div className="mt-8">
|
||||
<h3 className="text-lg font-bold text-gray-900 flex items-center gap-2 mb-4 border-b pb-2">
|
||||
<DollarSign size={20} className="text-brand-gold" />
|
||||
Dados Financeiros
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div className="bg-gray-50 p-3 rounded-lg border border-gray-100">
|
||||
<span className="block text-gray-500 text-xs uppercase tracking-wider font-semibold mb-1">CPF/CNPJ Titular</span>
|
||||
<span className="font-medium text-gray-900">{viewProfessional.cpf_cnpj_titular || "-"}</span>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-3 rounded-lg border border-gray-100">
|
||||
<span className="block text-gray-500 text-xs uppercase tracking-wider font-semibold mb-1">Chave Pix</span>
|
||||
<span className="font-medium text-gray-900">{viewProfessional.conta_pix || "-"}</span>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-3 rounded-lg border border-gray-100">
|
||||
<span className="block text-gray-500 text-xs uppercase tracking-wider font-semibold mb-1">Banco / Agência</span>
|
||||
<span className="font-medium text-gray-900">
|
||||
{viewProfessional.banco || "-"}{viewProfessional.agencia ? ` / ${viewProfessional.agencia}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-3 rounded-lg border border-gray-100">
|
||||
<span className="block text-gray-500 text-xs uppercase tracking-wider font-semibold mb-1">Tabela Free</span>
|
||||
<span className="font-medium text-gray-900">R$ {viewProfessional.tabela_free || "0,00"}</span>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-3 rounded-lg border border-gray-100">
|
||||
<span className="block text-gray-500 text-xs uppercase tracking-wider font-semibold mb-1">Tipo de Cartão</span>
|
||||
<span className="font-medium text-gray-900">{viewProfessional.tipo_cartao || "-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Performance / Observations */}
|
||||
<div className="mt-8 bg-brand-gold/5 rounded-xl p-6 border border-brand-gold/10">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-2 bg-white rounded-full text-brand-gold shadow-sm">
|
||||
<Star size={24} className="text-brand-gold fill-brand-gold" />
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<h4 className="text-lg font-bold text-gray-900 mb-4">Performance & Avaliação</h4>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||
<div className="text-center bg-white p-2 rounded shadow-sm">
|
||||
<div className="text-xs text-gray-500 mb-1">Técnica</div>
|
||||
<div className="font-bold text-lg text-gray-800">{viewProfessional.qual_tec || 0}</div>
|
||||
</div>
|
||||
<div className="text-center bg-white p-2 rounded shadow-sm">
|
||||
<div className="text-xs text-gray-500 mb-1">Simpatia</div>
|
||||
<div className="font-bold text-lg text-gray-800">{viewProfessional.educacao_simpatia || 0}</div>
|
||||
</div>
|
||||
<div className="text-center bg-white p-2 rounded shadow-sm">
|
||||
<div className="text-xs text-gray-500 mb-1">Desempenho</div>
|
||||
<div className="font-bold text-lg text-gray-800">{viewProfessional.desempenho_evento || 0}</div>
|
||||
</div>
|
||||
<div className="text-center bg-white p-2 rounded shadow-sm">
|
||||
<div className="text-xs text-gray-500 mb-1">Horário</div>
|
||||
<div className="font-bold text-lg text-gray-800">{viewProfessional.disp_horario || 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-600 text-sm leading-relaxed mb-2">
|
||||
Média Geral: <strong>{viewProfessional.media ? (typeof viewProfessional.media === 'number' ? viewProfessional.media.toFixed(1) : parseFloat(viewProfessional.media).toFixed(1)) : "N/A"}</strong>
|
||||
</p>
|
||||
{viewProfessional.observacao && (
|
||||
<div className="mt-3 text-sm text-gray-500 italic border-t border-brand-gold/10 pt-2">
|
||||
"{viewProfessional.observacao}"
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-6 bg-gray-50 border-t border-gray-100 flex justify-end gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setViewProfessional(null)}
|
||||
>
|
||||
Fechar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setViewProfessional(null);
|
||||
handleEditClick(viewProfessional);
|
||||
}}
|
||||
>
|
||||
<Edit2 size={16} className="mr-2" />
|
||||
Editar Dados
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ProfessionalDetailsModal
|
||||
professional={viewProfessional}
|
||||
isOpen={!!viewProfessional}
|
||||
onClose={() => setViewProfessional(null)}
|
||||
onEdit={() => {
|
||||
const p = viewProfessional;
|
||||
setViewProfessional(null);
|
||||
handleEditClick(p);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -838,3 +838,173 @@ export async function uploadFileToSignedUrl(uploadUrl: string, file: File): Prom
|
|||
throw new Error(`Failed to upload file to S3. Status: ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Escalas / Scheduling ---
|
||||
|
||||
export interface EscalaInput {
|
||||
agenda_id: string;
|
||||
profissional_id: string;
|
||||
data_hora_inicio: string; // ISO String
|
||||
data_hora_fim: string; // ISO String
|
||||
funcao_especifica?: string;
|
||||
}
|
||||
|
||||
export async function createEscala(data: EscalaInput, token: string): Promise<ApiResponse<{ id: string }>> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/escalas`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
return { data: responseData, error: null, isBackendDown: false };
|
||||
} catch (error) {
|
||||
return { data: null, error: error instanceof Error ? error.message : "Erro desconhecido", isBackendDown: true };
|
||||
}
|
||||
}
|
||||
|
||||
export async function listEscalas(agendaId: string, token: string): Promise<ApiResponse<any[]>> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/escalas?agenda_id=${agendaId}`, {
|
||||
headers: { "Authorization": `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
return { data: responseData, error: null, isBackendDown: false };
|
||||
} catch (error) {
|
||||
return { data: null, error: error instanceof Error ? error.message : "Erro desconhecido", isBackendDown: true };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteEscala(id: string, token: string): Promise<ApiResponse<any>> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/escalas/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: { "Authorization": `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return { data: { message: "deleted" }, error: null, isBackendDown: false };
|
||||
} catch (error) {
|
||||
return { data: null, error: error instanceof Error ? error.message : "Erro desconhecido", isBackendDown: true };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateEscala(id: string, data: Partial<EscalaInput>, token: string): Promise<ApiResponse<any>> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/escalas/${id}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return { data: { message: "updated" }, error: null, isBackendDown: false };
|
||||
} catch (error) {
|
||||
return { data: null, error: error instanceof Error ? error.message : "Erro desconhecido", isBackendDown: true };
|
||||
}
|
||||
}
|
||||
|
||||
// --- Logistica (Carros/Passageiros) ---
|
||||
|
||||
export interface CarroInput {
|
||||
agenda_id: string;
|
||||
motorista_id?: string;
|
||||
nome_motorista?: string;
|
||||
horario_chegada?: string;
|
||||
observacoes?: string;
|
||||
}
|
||||
|
||||
export async function createCarro(data: CarroInput, token: string): Promise<ApiResponse<{ id: string }>> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/logistica/carros`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const resData = await response.json();
|
||||
if (!response.ok) throw new Error(resData.error || "Erro ao criar carro");
|
||||
return { data: resData, error: null, isBackendDown: false };
|
||||
} catch (err: any) { return { data: null, error: err.message, isBackendDown: true }; }
|
||||
}
|
||||
|
||||
export async function listCarros(agendaId: string, token: string): Promise<ApiResponse<any[]>> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/logistica/carros?agenda_id=${agendaId}`, {
|
||||
headers: { "Authorization": `Bearer ${token}` },
|
||||
});
|
||||
const resData = await response.json();
|
||||
if (!response.ok) throw new Error(resData.error || "Erro ao listar carros");
|
||||
return { data: resData, error: null, isBackendDown: false };
|
||||
} catch (err: any) { return { data: null, error: err.message, isBackendDown: true }; }
|
||||
}
|
||||
|
||||
export async function deleteCarro(id: string, token: string): Promise<ApiResponse<any>> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/logistica/carros/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: { "Authorization": `Bearer ${token}` },
|
||||
});
|
||||
if (!response.ok) throw new Error("Erro ao deletar carro");
|
||||
return { data: { message: "deleted" }, error: null, isBackendDown: false };
|
||||
} catch (err: any) { return { data: null, error: err.message, isBackendDown: true }; }
|
||||
}
|
||||
|
||||
export async function addPassenger(carroId: string, profissionalId: string, token: string) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/logistica/carros/${carroId}/passageiros`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
|
||||
body: JSON.stringify({ profissional_id: profissionalId })
|
||||
});
|
||||
if (!response.ok) throw new Error("Erro ao adicionar passageiro");
|
||||
return { error: null };
|
||||
} catch (err: any) { return { error: err.message }; }
|
||||
}
|
||||
|
||||
export async function removePassenger(carroId: string, profissionalId: string, token: string) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/logistica/carros/${carroId}/passageiros/${profissionalId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
if (!response.ok) throw new Error("Erro ao remover passageiro");
|
||||
return { error: null };
|
||||
} catch (err: any) { return { error: err.message }; }
|
||||
}
|
||||
|
||||
export async function listPassengers(carroId: string, token: string) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/logistica/carros/${carroId}/passageiros`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error("Erro ao listar passageiros");
|
||||
return { data, error: null };
|
||||
} catch (err: any) { return { data: null, error: err.message }; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -140,6 +140,7 @@ export interface EventData {
|
|||
empresa?: string; // Nome da Empresa
|
||||
observacoes?: string; // Observações da FOT
|
||||
tipoEventoNome?: string; // Nome do Tipo de Evento
|
||||
local_evento?: string; // Nome do Local (Salão, Igreja, etc)
|
||||
|
||||
assignments?: Assignment[]; // Lista de status de atribuições
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue