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/cursos"
|
||||||
"photum-backend/internal/db"
|
"photum-backend/internal/db"
|
||||||
"photum-backend/internal/empresas"
|
"photum-backend/internal/empresas"
|
||||||
|
"photum-backend/internal/escalas"
|
||||||
"photum-backend/internal/funcoes"
|
"photum-backend/internal/funcoes"
|
||||||
|
"photum-backend/internal/logistica"
|
||||||
"photum-backend/internal/profissionais"
|
"photum-backend/internal/profissionais"
|
||||||
"photum-backend/internal/storage"
|
"photum-backend/internal/storage"
|
||||||
"photum-backend/internal/tipos_eventos"
|
"photum-backend/internal/tipos_eventos"
|
||||||
|
|
@ -92,6 +94,8 @@ func main() {
|
||||||
cadastroFotHandler := cadastro_fot.NewHandler(cadastroFotService)
|
cadastroFotHandler := cadastro_fot.NewHandler(cadastroFotService)
|
||||||
agendaHandler := agenda.NewHandler(agendaService)
|
agendaHandler := agenda.NewHandler(agendaService)
|
||||||
availabilityHandler := availability.NewHandler(availabilityService)
|
availabilityHandler := availability.NewHandler(availabilityService)
|
||||||
|
escalasHandler := escalas.NewHandler(escalas.NewService(queries))
|
||||||
|
logisticaHandler := logistica.NewHandler(logistica.NewService(queries))
|
||||||
|
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
|
|
||||||
|
|
@ -207,6 +211,25 @@ func main() {
|
||||||
api.POST("/availability", availabilityHandler.SetAvailability)
|
api.POST("/availability", availabilityHandler.SetAvailability)
|
||||||
api.GET("/availability", availabilityHandler.ListAvailability)
|
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 := api.Group("/admin")
|
||||||
{
|
{
|
||||||
admin.GET("/users", authHandler.ListUsers)
|
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": {
|
"/api/funcoes": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "List all professional functions",
|
"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": {
|
"funcoes.CreateFuncaoRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"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": {
|
"/api/funcoes": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "List all professional functions",
|
"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": {
|
"funcoes.CreateFuncaoRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
|
|
|
||||||
|
|
@ -238,6 +238,32 @@ definitions:
|
||||||
nome:
|
nome:
|
||||||
type: string
|
type: string
|
||||||
type: object
|
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:
|
funcoes.CreateFuncaoRequest:
|
||||||
properties:
|
properties:
|
||||||
nome:
|
nome:
|
||||||
|
|
@ -1348,6 +1374,104 @@ paths:
|
||||||
summary: Update a company
|
summary: Update a company
|
||||||
tags:
|
tags:
|
||||||
- empresas
|
- 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:
|
/api/funcoes:
|
||||||
get:
|
get:
|
||||||
consumes:
|
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"`
|
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 {
|
type AgendaProfissionai struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
AgendaID pgtype.UUID `json:"agenda_id"`
|
AgendaID pgtype.UUID `json:"agenda_id"`
|
||||||
|
|
@ -140,6 +151,42 @@ type FuncoesProfissionai struct {
|
||||||
AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"`
|
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 {
|
type PrecosTiposEvento struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
TipoEventoID pgtype.UUID `json:"tipo_evento_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(),
|
criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
UNIQUE(usuario_id, data)
|
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 { Register } from "./pages/Register";
|
||||||
import { ProfessionalRegister } from "./pages/ProfessionalRegister";
|
import { ProfessionalRegister } from "./pages/ProfessionalRegister";
|
||||||
import { TeamPage } from "./pages/Team";
|
import { TeamPage } from "./pages/Team";
|
||||||
|
import EventDetails from "./pages/EventDetails";
|
||||||
import Finance from "./pages/Finance";
|
import Finance from "./pages/Finance";
|
||||||
import PhotographerFinance from "./pages/PhotographerFinance";
|
import PhotographerFinance from "./pages/PhotographerFinance";
|
||||||
import { SettingsPage } from "./pages/Settings";
|
import { SettingsPage } from "./pages/Settings";
|
||||||
|
|
@ -431,6 +432,17 @@ const AppContent: React.FC = () => {
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/agenda/:id"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<PageWrapper>
|
||||||
|
<EventDetails />
|
||||||
|
</PageWrapper>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
|
||||||
{/* Rota de solicitação de evento - Clientes e Administradores */}
|
{/* Rota de solicitação de evento - Clientes e Administradores */}
|
||||||
<Route
|
<Route
|
||||||
|
|
@ -528,7 +540,7 @@ const AppContent: React.FC = () => {
|
||||||
allowedRoles={[UserRole.SUPERADMIN, UserRole.BUSINESS_OWNER]}
|
allowedRoles={[UserRole.SUPERADMIN, UserRole.BUSINESS_OWNER]}
|
||||||
>
|
>
|
||||||
<PageWrapper>
|
<PageWrapper>
|
||||||
<AccessCodeManagement onNavigate={(page) => {}} />
|
<AccessCodeManagement onNavigate={(page) => { }} />
|
||||||
</PageWrapper>
|
</PageWrapper>
|
||||||
</ProtectedRoute>
|
</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 { Button } from './Button';
|
||||||
import {
|
import {
|
||||||
X, Mail, Phone, MapPin, Building, Star, Camera, DollarSign, Award,
|
X, Mail, Phone, MapPin, Building, Star, Camera, DollarSign, Award,
|
||||||
User, Car, CreditCard, AlertTriangle
|
User, Car, CreditCard, AlertTriangle, Calendar, Clock, Edit2
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { getAgendas } from '../services/apiService';
|
||||||
|
|
||||||
interface ProfessionalDetailsModalProps {
|
interface ProfessionalDetailsModalProps {
|
||||||
professional: Professional;
|
professional: Professional;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onEdit?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { UserRole } from '../types';
|
||||||
|
|
||||||
|
// ... (imports remain)
|
||||||
|
|
||||||
export const ProfessionalDetailsModal: React.FC<ProfessionalDetailsModalProps> = ({
|
export const ProfessionalDetailsModal: React.FC<ProfessionalDetailsModalProps> = ({
|
||||||
professional,
|
professional,
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
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;
|
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 (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 animate-fadeIn">
|
<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">
|
<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... (remains same) */}
|
||||||
{/* Header com Capa/Avatar Style */}
|
|
||||||
<div className="h-32 bg-gradient-to-r from-brand-purple to-brand-purple/80 relative shrink-0">
|
<div className="h-32 bg-gradient-to-r from-brand-purple to-brand-purple/80 relative shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
|
@ -33,10 +73,8 @@ export const ProfessionalDetailsModal: React.FC<ProfessionalDetailsModalProps> =
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Conteúdo Principal */}
|
|
||||||
<div className="px-8 pb-8 -mt-16 flex flex-col items-center sm:items-start relative z-10">
|
<div className="px-8 pb-8 -mt-16 flex flex-col items-center sm:items-start relative z-10">
|
||||||
|
{/* Avatar... (remains same) */}
|
||||||
{/* Avatar Grande */}
|
|
||||||
<div
|
<div
|
||||||
className="w-32 h-32 rounded-full border-4 border-white bg-white shadow-lg mb-4 bg-gray-200"
|
className="w-32 h-32 rounded-full border-4 border-white bg-white shadow-lg mb-4 bg-gray-200"
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -53,7 +91,8 @@ export const ProfessionalDetailsModal: React.FC<ProfessionalDetailsModalProps> =
|
||||||
<User size={14} />
|
<User size={14} />
|
||||||
{professional.role || "Profissional"}
|
{professional.role || "Profissional"}
|
||||||
</span>
|
</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">
|
<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" />
|
<Star size={14} className="fill-yellow-500 text-yellow-500" />
|
||||||
{typeof professional.media === 'number' ? professional.media.toFixed(1) : parseFloat(String(professional.media)).toFixed(1)}
|
{typeof professional.media === 'number' ? professional.media.toFixed(1) : parseFloat(String(professional.media)).toFixed(1)}
|
||||||
|
|
@ -63,14 +102,14 @@ export const ProfessionalDetailsModal: React.FC<ProfessionalDetailsModalProps> =
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full grid grid-cols-1 md:grid-cols-2 gap-8 mt-8">
|
<div className="w-full grid grid-cols-1 md:grid-cols-2 gap-8 mt-8">
|
||||||
|
{/* Dados Pessoais - Protected */}
|
||||||
{/* Dados Pessoais */}
|
|
||||||
<div className="space-y-4">
|
<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">
|
<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" />
|
<User size={20} className="text-brand-gold" />
|
||||||
Dados Pessoais
|
Dados Pessoais
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
|
{isAdminOrOwner ? (
|
||||||
<div className="space-y-4 text-sm">
|
<div className="space-y-4 text-sm">
|
||||||
{professional.email && (
|
{professional.email && (
|
||||||
<div className="flex items-start gap-3 text-gray-600">
|
<div className="flex items-start gap-3 text-gray-600">
|
||||||
|
|
@ -97,9 +136,15 @@ export const ProfessionalDetailsModal: React.FC<ProfessionalDetailsModalProps> =
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Equipamentos */}
|
{/* Equipamentos - Public */}
|
||||||
<div className="space-y-4">
|
<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">
|
<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" />
|
<Camera size={20} className="text-brand-gold" />
|
||||||
|
|
@ -130,7 +175,9 @@ export const ProfessionalDetailsModal: React.FC<ProfessionalDetailsModalProps> =
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dados Financeiros */}
|
{/* Dados Financeiros & Performance - Protected */}
|
||||||
|
{isAdminOrOwner && (
|
||||||
|
<>
|
||||||
<div className="w-full mt-8">
|
<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">
|
<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" />
|
<DollarSign size={20} className="text-brand-gold" />
|
||||||
|
|
@ -161,8 +208,11 @@ export const ProfessionalDetailsModal: React.FC<ProfessionalDetailsModalProps> =
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Performance / Observations */}
|
{/* 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="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="flex items-start gap-4">
|
||||||
<div className="p-2 bg-white rounded-full text-brand-gold shadow-sm">
|
<div className="p-2 bg-white rounded-full text-brand-gold shadow-sm">
|
||||||
|
|
@ -201,11 +251,53 @@ export const ProfessionalDetailsModal: React.FC<ProfessionalDetailsModalProps> =
|
||||||
</div>
|
</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}>
|
<Button variant="outline" onClick={onClose}>
|
||||||
Fechar
|
Fechar
|
||||||
</Button>
|
</Button>
|
||||||
|
{onEdit && (
|
||||||
|
<Button onClick={onEdit}>
|
||||||
|
<Edit2 size={16} className="mr-2" />
|
||||||
|
Editar Dados
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React, { useState, useEffect, useMemo } from "react";
|
import React, { useState, useEffect, useMemo } from "react";
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { UserRole, EventData, EventStatus, EventType, Professional } from "../types";
|
import { UserRole, EventData, EventStatus, EventType, Professional } from "../types";
|
||||||
import { EventTable } from "../components/EventTable";
|
import { EventTable } from "../components/EventTable";
|
||||||
import { EventFiltersBar, EventFilters } from "../components/EventFiltersBar";
|
import { EventFiltersBar, EventFilters } from "../components/EventFiltersBar";
|
||||||
|
|
@ -32,6 +33,7 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
||||||
initialView = "list",
|
initialView = "list",
|
||||||
}) => {
|
}) => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
const {
|
const {
|
||||||
events,
|
events,
|
||||||
getEventsByRole,
|
getEventsByRole,
|
||||||
|
|
@ -396,8 +398,15 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
{/* Actions Toolbar */}
|
|
||||||
<div className="flex flex-wrap gap-2 mb-6 pb-4 border-b">
|
<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.BUSINESS_OWNER ||
|
||||||
user.role === UserRole.SUPERADMIN) && (
|
user.role === UserRole.SUPERADMIN) && (
|
||||||
<>
|
<>
|
||||||
|
|
@ -434,6 +443,24 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
||||||
>
|
>
|
||||||
<Map size={16} className="mr-2" /> Abrir no Maps
|
<Map size={16} className="mr-2" /> Abrir no Maps
|
||||||
</Button>
|
</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>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<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 status = assignment ? assignment.status : null;
|
||||||
const isAssigned = !!status && status !== "REJEITADO"; // Consider assigned if not rejected (effectively)
|
const isAssigned = !!status && status !== "REJEITADO";
|
||||||
const isAvailable = true;
|
|
||||||
|
// 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 (
|
return (
|
||||||
<tr
|
<tr
|
||||||
|
|
@ -940,9 +973,9 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!status && (
|
{!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">
|
<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"}`}>
|
||||||
<UserCheck size={14} />
|
{isBusy ? <UserX size={14} /> : <UserCheck size={14} />}
|
||||||
Disponível
|
{isBusy ? "Em outro evento" : "Disponível"}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -953,7 +986,7 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
togglePhotographer(photographer.id)
|
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"
|
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-red-100 text-red-700 hover:bg-red-200"
|
||||||
: "bg-brand-gold text-white hover:bg-[#a5bd2e]"
|
: "bg-brand-gold text-white hover:bg-[#a5bd2e]"
|
||||||
|
|
@ -993,6 +1026,13 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
||||||
);
|
);
|
||||||
const status = assignment ? assignment.status : null;
|
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 (
|
return (
|
||||||
<div key={photographer.id} className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm">
|
<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)}>
|
<div className="flex items-center gap-3 mb-3" onClick={() => handleViewProfessional(photographer)}>
|
||||||
|
|
@ -1030,8 +1070,9 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!status && (
|
{!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">
|
<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"}`}>
|
||||||
<UserCheck size={12} /> Disponível
|
{isBusy ? <UserX size={12} /> : <UserCheck size={12} />}
|
||||||
|
{isBusy ? "Em outro evento" : "Disponível"}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1045,12 +1086,13 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => togglePhotographer(photographer.id)}
|
onClick={() => togglePhotographer(photographer.id)}
|
||||||
|
disabled={!status && isBusy}
|
||||||
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-colors ${status === "ACEITO" || status === "PENDENTE"
|
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-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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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";
|
} from "../services/apiService";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
import { Professional, CreateProfessionalDTO } from "../types";
|
import { Professional, CreateProfessionalDTO } from "../types";
|
||||||
|
import { ProfessionalDetailsModal } from "../components/ProfessionalDetailsModal";
|
||||||
|
|
||||||
export const TeamPage: React.FC = () => {
|
export const TeamPage: React.FC = () => {
|
||||||
const { user, token: contextToken } = useAuth();
|
const { user, token: contextToken } = useAuth();
|
||||||
|
|
@ -821,205 +822,16 @@ export const TeamPage: React.FC = () => {
|
||||||
|
|
||||||
{/* View Modal */}
|
{/* View Modal */}
|
||||||
{viewProfessional && (
|
{viewProfessional && (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 animate-fadeIn">
|
<ProfessionalDetailsModal
|
||||||
<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">
|
professional={viewProfessional}
|
||||||
{/* Header / Avatar Section */}
|
isOpen={!!viewProfessional}
|
||||||
<div className="relative pt-12 pb-6 px-8 text-center bg-gradient-to-b from-gray-50 to-white">
|
onClose={() => setViewProfessional(null)}
|
||||||
<button
|
onEdit={() => {
|
||||||
onClick={() => setViewProfessional(null)}
|
const p = viewProfessional;
|
||||||
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);
|
setViewProfessional(null);
|
||||||
handleEditClick(viewProfessional);
|
handleEditClick(p);
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<Edit2 size={16} className="mr-2" />
|
|
||||||
Editar Dados
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</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}`);
|
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
|
empresa?: string; // Nome da Empresa
|
||||||
observacoes?: string; // Observações da FOT
|
observacoes?: string; // Observações da FOT
|
||||||
tipoEventoNome?: string; // Nome do Tipo de Evento
|
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
|
assignments?: Assignment[]; // Lista de status de atribuições
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue