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:
Andre F. Rodrigues 2025-12-29 16:04:04 -03:00 committed by GitHub
commit 030b78d787
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 2984 additions and 310 deletions

View file

@ -14,7 +14,9 @@ import (
"photum-backend/internal/cursos"
"photum-backend/internal/db"
"photum-backend/internal/empresas"
"photum-backend/internal/escalas"
"photum-backend/internal/funcoes"
"photum-backend/internal/logistica"
"photum-backend/internal/profissionais"
"photum-backend/internal/storage"
"photum-backend/internal/tipos_eventos"
@ -92,6 +94,8 @@ func main() {
cadastroFotHandler := cadastro_fot.NewHandler(cadastroFotService)
agendaHandler := agenda.NewHandler(agendaService)
availabilityHandler := availability.NewHandler(availabilityService)
escalasHandler := escalas.NewHandler(escalas.NewService(queries))
logisticaHandler := logistica.NewHandler(logistica.NewService(queries))
r := gin.Default()
@ -207,6 +211,25 @@ func main() {
api.POST("/availability", availabilityHandler.SetAvailability)
api.GET("/availability", availabilityHandler.ListAvailability)
// Escalas Routes
api.POST("/escalas", escalasHandler.Create)
api.GET("/escalas", escalasHandler.ListByAgenda)
api.DELETE("/escalas/:id", escalasHandler.Delete)
api.PUT("/escalas/:id", escalasHandler.Update)
// Logistics Routes
logisticaGroup := api.Group("/logistica")
{
logisticaGroup.POST("/carros", logisticaHandler.CreateCarro)
logisticaGroup.GET("/carros", logisticaHandler.ListCarros)
logisticaGroup.DELETE("/carros/:id", logisticaHandler.DeleteCarro)
logisticaGroup.PUT("/carros/:id", logisticaHandler.UpdateCarro)
logisticaGroup.POST("/carros/:id/passageiros", logisticaHandler.AddPassenger)
logisticaGroup.DELETE("/carros/:id/passageiros/:profID", logisticaHandler.RemovePassenger)
logisticaGroup.GET("/carros/:id/passageiros", logisticaHandler.ListPassengers)
}
admin := api.Group("/admin")
{
admin.GET("/users", authHandler.ListUsers)

View file

@ -1376,6 +1376,154 @@ const docTemplate = `{
}
}
},
"/api/escalas": {
"get": {
"description": "Get all schedule entries for a specific agenda_id",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"escalas"
],
"summary": "List schedules for an event",
"parameters": [
{
"type": "string",
"description": "Agenda ID",
"name": "agenda_id",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"post": {
"description": "Assign a professional to a time block in an event",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"escalas"
],
"summary": "Create a schedule entry",
"parameters": [
{
"description": "Create Escala",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/escalas.CreateEscalaInput"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/escalas/{id}": {
"put": {
"description": "Update time or professional for a slot",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"escalas"
],
"summary": "Update a schedule entry",
"parameters": [
{
"type": "string",
"description": "Escala ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "Update Escala",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/escalas.UpdateEscalaInput"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
},
"delete": {
"description": "Remove a professional from a time block",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"escalas"
],
"summary": "Delete a schedule entry",
"parameters": [
{
"type": "string",
"description": "Escala ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/funcoes": {
"get": {
"description": "List all professional functions",
@ -2942,6 +3090,45 @@ const docTemplate = `{
}
}
},
"escalas.CreateEscalaInput": {
"type": "object",
"properties": {
"agenda_id": {
"type": "string"
},
"data_hora_fim": {
"description": "RFC3339",
"type": "string"
},
"data_hora_inicio": {
"description": "RFC3339",
"type": "string"
},
"funcao_especifica": {
"type": "string"
},
"profissional_id": {
"type": "string"
}
}
},
"escalas.UpdateEscalaInput": {
"type": "object",
"properties": {
"data_hora_fim": {
"type": "string"
},
"data_hora_inicio": {
"type": "string"
},
"funcao_especifica": {
"type": "string"
},
"profissional_id": {
"type": "string"
}
}
},
"funcoes.CreateFuncaoRequest": {
"type": "object",
"required": [

View file

@ -1370,6 +1370,154 @@
}
}
},
"/api/escalas": {
"get": {
"description": "Get all schedule entries for a specific agenda_id",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"escalas"
],
"summary": "List schedules for an event",
"parameters": [
{
"type": "string",
"description": "Agenda ID",
"name": "agenda_id",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"post": {
"description": "Assign a professional to a time block in an event",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"escalas"
],
"summary": "Create a schedule entry",
"parameters": [
{
"description": "Create Escala",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/escalas.CreateEscalaInput"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/escalas/{id}": {
"put": {
"description": "Update time or professional for a slot",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"escalas"
],
"summary": "Update a schedule entry",
"parameters": [
{
"type": "string",
"description": "Escala ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "Update Escala",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/escalas.UpdateEscalaInput"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
},
"delete": {
"description": "Remove a professional from a time block",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"escalas"
],
"summary": "Delete a schedule entry",
"parameters": [
{
"type": "string",
"description": "Escala ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/funcoes": {
"get": {
"description": "List all professional functions",
@ -2936,6 +3084,45 @@
}
}
},
"escalas.CreateEscalaInput": {
"type": "object",
"properties": {
"agenda_id": {
"type": "string"
},
"data_hora_fim": {
"description": "RFC3339",
"type": "string"
},
"data_hora_inicio": {
"description": "RFC3339",
"type": "string"
},
"funcao_especifica": {
"type": "string"
},
"profissional_id": {
"type": "string"
}
}
},
"escalas.UpdateEscalaInput": {
"type": "object",
"properties": {
"data_hora_fim": {
"type": "string"
},
"data_hora_inicio": {
"type": "string"
},
"funcao_especifica": {
"type": "string"
},
"profissional_id": {
"type": "string"
}
}
},
"funcoes.CreateFuncaoRequest": {
"type": "object",
"required": [

View file

@ -238,6 +238,32 @@ definitions:
nome:
type: string
type: object
escalas.CreateEscalaInput:
properties:
agenda_id:
type: string
data_hora_fim:
description: RFC3339
type: string
data_hora_inicio:
description: RFC3339
type: string
funcao_especifica:
type: string
profissional_id:
type: string
type: object
escalas.UpdateEscalaInput:
properties:
data_hora_fim:
type: string
data_hora_inicio:
type: string
funcao_especifica:
type: string
profissional_id:
type: string
type: object
funcoes.CreateFuncaoRequest:
properties:
nome:
@ -1348,6 +1374,104 @@ paths:
summary: Update a company
tags:
- empresas
/api/escalas:
get:
consumes:
- application/json
description: Get all schedule entries for a specific agenda_id
parameters:
- description: Agenda ID
in: query
name: agenda_id
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
additionalProperties: true
type: object
type: array
summary: List schedules for an event
tags:
- escalas
post:
consumes:
- application/json
description: Assign a professional to a time block in an event
parameters:
- description: Create Escala
in: body
name: request
required: true
schema:
$ref: '#/definitions/escalas.CreateEscalaInput'
produces:
- application/json
responses:
"201":
description: Created
schema:
additionalProperties:
type: string
type: object
summary: Create a schedule entry
tags:
- escalas
/api/escalas/{id}:
delete:
consumes:
- application/json
description: Remove a professional from a time block
parameters:
- description: Escala ID
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties:
type: string
type: object
summary: Delete a schedule entry
tags:
- escalas
put:
consumes:
- application/json
description: Update time or professional for a slot
parameters:
- description: Escala ID
in: path
name: id
required: true
type: string
- description: Update Escala
in: body
name: request
required: true
schema:
$ref: '#/definitions/escalas.UpdateEscalaInput'
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties:
type: string
type: object
summary: Update a schedule entry
tags:
- escalas
/api/funcoes:
get:
consumes:

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

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

View file

@ -39,6 +39,17 @@ type Agenda struct {
Status pgtype.Text `json:"status"`
}
type AgendaEscala struct {
ID pgtype.UUID `json:"id"`
AgendaID pgtype.UUID `json:"agenda_id"`
ProfissionalID pgtype.UUID `json:"profissional_id"`
DataHoraInicio pgtype.Timestamptz `json:"data_hora_inicio"`
DataHoraFim pgtype.Timestamptz `json:"data_hora_fim"`
FuncaoEspecifica pgtype.Text `json:"funcao_especifica"`
CriadoEm pgtype.Timestamptz `json:"criado_em"`
AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"`
}
type AgendaProfissionai struct {
ID pgtype.UUID `json:"id"`
AgendaID pgtype.UUID `json:"agenda_id"`
@ -140,6 +151,42 @@ type FuncoesProfissionai struct {
AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"`
}
type LogisticaCarro struct {
ID pgtype.UUID `json:"id"`
AgendaID pgtype.UUID `json:"agenda_id"`
MotoristaID pgtype.UUID `json:"motorista_id"`
NomeMotorista pgtype.Text `json:"nome_motorista"`
HorarioChegada pgtype.Text `json:"horario_chegada"`
Observacoes pgtype.Text `json:"observacoes"`
CriadoEm pgtype.Timestamptz `json:"criado_em"`
AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"`
}
type LogisticaPassageiro struct {
ID pgtype.UUID `json:"id"`
CarroID pgtype.UUID `json:"carro_id"`
ProfissionalID pgtype.UUID `json:"profissional_id"`
CriadoEm pgtype.Timestamptz `json:"criado_em"`
}
type MapasEvento struct {
ID pgtype.UUID `json:"id"`
AgendaID pgtype.UUID `json:"agenda_id"`
Nome pgtype.Text `json:"nome"`
ImagemUrl pgtype.Text `json:"imagem_url"`
CriadoEm pgtype.Timestamptz `json:"criado_em"`
}
type MarcadoresMapa struct {
ID pgtype.UUID `json:"id"`
MapaID pgtype.UUID `json:"mapa_id"`
ProfissionalID pgtype.UUID `json:"profissional_id"`
PosX pgtype.Numeric `json:"pos_x"`
PosY pgtype.Numeric `json:"pos_y"`
Rotulo pgtype.Text `json:"rotulo"`
CriadoEm pgtype.Timestamptz `json:"criado_em"`
}
type PrecosTiposEvento struct {
ID pgtype.UUID `json:"id"`
TipoEventoID pgtype.UUID `json:"tipo_evento_id"`

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

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

View file

@ -368,3 +368,56 @@ CREATE TABLE IF NOT EXISTS disponibilidade_profissionais (
criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(usuario_id, data)
);
-- Agenda Escalas (Time blocks for professionals in an event)
CREATE TABLE IF NOT EXISTS agenda_escalas (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
agenda_id UUID NOT NULL REFERENCES agenda(id) ON DELETE CASCADE,
profissional_id UUID NOT NULL REFERENCES cadastro_profissionais(id) ON DELETE CASCADE,
data_hora_inicio TIMESTAMPTZ NOT NULL,
data_hora_fim TIMESTAMPTZ NOT NULL,
funcao_especifica VARCHAR(100), -- e.g. "PalcoDireito", "Entrada"
criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
atualizado_em TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Mapas de Eventos (Floor plans)
CREATE TABLE IF NOT EXISTS mapas_eventos (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
agenda_id UUID NOT NULL REFERENCES agenda(id) ON DELETE CASCADE,
nome VARCHAR(100), -- e.g. "Salão Principal"
imagem_url TEXT,
criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Marcadores no Mapa (Pins)
CREATE TABLE IF NOT EXISTS marcadores_mapa (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
mapa_id UUID NOT NULL REFERENCES mapas_eventos(id) ON DELETE CASCADE,
profissional_id UUID REFERENCES cadastro_profissionais(id) ON DELETE CASCADE,
pos_x NUMERIC(5,2), -- Percentage 0-100
pos_y NUMERIC(5,2),
rotulo VARCHAR(50),
criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Logística de Transporte (Cars)
CREATE TABLE IF NOT EXISTS logistica_carros (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
agenda_id UUID NOT NULL REFERENCES agenda(id) ON DELETE CASCADE,
motorista_id UUID REFERENCES cadastro_profissionais(id) ON DELETE SET NULL, -- Driver is usually a professional
nome_motorista VARCHAR(255), -- Fallback if not a system professional or just custom name
horario_chegada VARCHAR(20), -- e.g. "7h00"
observacoes TEXT,
criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
atualizado_em TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Passageiros (Passengers in the car)
CREATE TABLE IF NOT EXISTS logistica_passageiros (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
carro_id UUID NOT NULL REFERENCES logistica_carros(id) ON DELETE CASCADE,
profissional_id UUID NOT NULL REFERENCES cadastro_profissionais(id) ON DELETE CASCADE,
criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(carro_id, profissional_id)
);

View 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"})
}

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

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

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

View file

@ -14,6 +14,7 @@ import { Login } from "./pages/Login";
import { Register } from "./pages/Register";
import { ProfessionalRegister } from "./pages/ProfessionalRegister";
import { TeamPage } from "./pages/Team";
import EventDetails from "./pages/EventDetails";
import Finance from "./pages/Finance";
import PhotographerFinance from "./pages/PhotographerFinance";
import { SettingsPage } from "./pages/Settings";
@ -431,6 +432,17 @@ const AppContent: React.FC = () => {
</ProtectedRoute>
}
/>
<Route
path="/agenda/:id"
element={
<ProtectedRoute>
<PageWrapper>
<EventDetails />
</PageWrapper>
</ProtectedRoute>
}
/>
{/* Rota de solicitação de evento - Clientes e Administradores */}
<Route
@ -528,7 +540,7 @@ const AppContent: React.FC = () => {
allowedRoles={[UserRole.SUPERADMIN, UserRole.BUSINESS_OWNER]}
>
<PageWrapper>
<AccessCodeManagement onNavigate={(page) => {}} />
<AccessCodeManagement onNavigate={(page) => { }} />
</PageWrapper>
</ProtectedRoute>
}

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

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

View file

@ -3,27 +3,67 @@ import { Professional } from '../types';
import { Button } from './Button';
import {
X, Mail, Phone, MapPin, Building, Star, Camera, DollarSign, Award,
User, Car, CreditCard, AlertTriangle
User, Car, CreditCard, AlertTriangle, Calendar, Clock, Edit2
} from 'lucide-react';
import { getAgendas } from '../services/apiService';
interface ProfessionalDetailsModalProps {
professional: Professional;
isOpen: boolean;
onClose: () => void;
onEdit?: () => void;
}
import { useAuth } from '../contexts/AuthContext';
import { UserRole } from '../types';
// ... (imports remain)
export const ProfessionalDetailsModal: React.FC<ProfessionalDetailsModalProps> = ({
professional,
isOpen,
onClose,
onEdit
}) => {
const { user, token } = useAuth();
const [assignedEvents, setAssignedEvents] = React.useState<any[]>([]);
const [loadingEvents, setLoadingEvents] = React.useState(false);
React.useEffect(() => {
if (isOpen && user && token && (user.role === UserRole.SUPERADMIN || user.role === UserRole.BUSINESS_OWNER || user.id === professional.usuarioId)) {
fetchAssignments();
}
}, [isOpen, user, token, professional.id]);
const fetchAssignments = async () => {
setLoadingEvents(true);
const res = await getAgendas(token!);
if (res.data) {
// Filter events where professional is assigned
const filtered = res.data.filter((evt: any) =>
evt.assigned_professionals?.some((ap: any) => ap.professional_id === professional.id)
);
// Sort by date desc
filtered.sort((a: any, b: any) => new Date(b.data_evento).getTime() - new Date(a.data_evento).getTime());
setAssignedEvents(filtered);
}
setLoadingEvents(false);
};
if (!isOpen) return null;
const canViewDetails =
user?.role === UserRole.SUPERADMIN ||
user?.role === UserRole.BUSINESS_OWNER ||
(user?.id && professional.usuarioId && user.id === professional.usuarioId);
// Also check legacy/fallback logic if needed, but primary is role or ownership
const isAdminOrOwner = canViewDetails; // Keeping variable name for now or refactoring below -> refactoring below to use canViewDetails for clarity is better but to minimize diff noise we can keep it or rename it. Let's rename it to avoid confusion.
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 animate-fadeIn">
<div className="bg-white rounded-2xl shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-y-auto flex flex-col relative animate-slideIn">
{/* Header com Capa/Avatar Style */}
{/* Header... (remains same) */}
<div className="h-32 bg-gradient-to-r from-brand-purple to-brand-purple/80 relative shrink-0">
<button
onClick={onClose}
@ -33,10 +73,8 @@ export const ProfessionalDetailsModal: React.FC<ProfessionalDetailsModalProps> =
</button>
</div>
{/* Conteúdo Principal */}
<div className="px-8 pb-8 -mt-16 flex flex-col items-center sm:items-start relative z-10">
{/* Avatar Grande */}
{/* Avatar... (remains same) */}
<div
className="w-32 h-32 rounded-full border-4 border-white bg-white shadow-lg mb-4 bg-gray-200"
style={{
@ -53,7 +91,8 @@ export const ProfessionalDetailsModal: React.FC<ProfessionalDetailsModalProps> =
<User size={14} />
{professional.role || "Profissional"}
</span>
{professional.media !== undefined && professional.media !== null && (
{/* Performance Rating - Only for Admins */}
{isAdminOrOwner && professional.media !== undefined && professional.media !== null && (
<span className="px-3 py-1 bg-yellow-50 text-yellow-700 rounded-full text-sm font-medium border border-yellow-200 flex items-center gap-1">
<Star size={14} className="fill-yellow-500 text-yellow-500" />
{typeof professional.media === 'number' ? professional.media.toFixed(1) : parseFloat(String(professional.media)).toFixed(1)}
@ -63,43 +102,49 @@ export const ProfessionalDetailsModal: React.FC<ProfessionalDetailsModalProps> =
</div>
<div className="w-full grid grid-cols-1 md:grid-cols-2 gap-8 mt-8">
{/* Dados Pessoais */}
{/* Dados Pessoais - Protected */}
<div className="space-y-4">
<h3 className="text-lg font-bold text-gray-900 flex items-center gap-2 mb-4 border-b pb-2">
<User size={20} className="text-brand-gold" />
Dados Pessoais
</h3>
<div className="space-y-4 text-sm">
{professional.email && (
<div className="flex items-start gap-3 text-gray-600">
<Mail size={18} className="mt-1 shrink-0 text-gray-400" />
<span className="break-all">{professional.email}</span>
</div>
)}
{professional.whatsapp && (
<div className="flex items-start gap-3 text-gray-600">
<Phone size={18} className="mt-1 shrink-0 text-gray-400" />
<span>{professional.whatsapp}</span>
</div>
)}
{(professional.cidade || professional.uf) && (
<div className="flex items-start gap-3 text-gray-600">
<MapPin size={18} className="mt-1 shrink-0 text-gray-400" />
<span>{professional.cidade}{professional.cidade && professional.uf ? ", " : ""}{professional.uf}</span>
</div>
)}
{professional.endereco && (
<div className="flex items-start gap-3 text-gray-600">
<Building size={18} className="mt-1 shrink-0 text-gray-400" />
<span className="text-sm">{professional.endereco}</span>
</div>
)}
</div>
{isAdminOrOwner ? (
<div className="space-y-4 text-sm">
{professional.email && (
<div className="flex items-start gap-3 text-gray-600">
<Mail size={18} className="mt-1 shrink-0 text-gray-400" />
<span className="break-all">{professional.email}</span>
</div>
)}
{professional.whatsapp && (
<div className="flex items-start gap-3 text-gray-600">
<Phone size={18} className="mt-1 shrink-0 text-gray-400" />
<span>{professional.whatsapp}</span>
</div>
)}
{(professional.cidade || professional.uf) && (
<div className="flex items-start gap-3 text-gray-600">
<MapPin size={18} className="mt-1 shrink-0 text-gray-400" />
<span>{professional.cidade}{professional.cidade && professional.uf ? ", " : ""}{professional.uf}</span>
</div>
)}
{professional.endereco && (
<div className="flex items-start gap-3 text-gray-600">
<Building size={18} className="mt-1 shrink-0 text-gray-400" />
<span className="text-sm">{professional.endereco}</span>
</div>
)}
</div>
) : (
<div className="p-4 bg-gray-50 rounded-lg border border-gray-100 text-center">
<AlertTriangle className="w-8 h-8 text-gray-400 mx-auto mb-2" />
<p className="text-gray-500 text-sm">Informações de contato restritas.</p>
</div>
)}
</div>
{/* Equipamentos */}
{/* Equipamentos - Public */}
<div className="space-y-4">
<h3 className="text-lg font-bold text-gray-900 flex items-center gap-2 mb-4 border-b pb-2">
<Camera size={20} className="text-brand-gold" />
@ -130,82 +175,129 @@ export const ProfessionalDetailsModal: React.FC<ProfessionalDetailsModalProps> =
</div>
{/* Dados Financeiros */}
<div className="w-full mt-8">
<h3 className="text-lg font-bold text-gray-900 flex items-center gap-2 mb-4 border-b pb-2">
<DollarSign size={20} className="text-brand-gold" />
Dados Financeiros
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm w-full">
<div className="bg-gray-50 p-3 rounded-lg border border-gray-100">
<span className="block text-gray-500 text-xs uppercase tracking-wider font-semibold mb-1">CPF/CNPJ Titular</span>
<span className="font-medium text-gray-900">{professional.cpf_cnpj_titular || "-"}</span>
</div>
<div className="bg-gray-50 p-3 rounded-lg border border-gray-100">
<span className="block text-gray-500 text-xs uppercase tracking-wider font-semibold mb-1">Chave Pix</span>
<span className="font-medium text-gray-900">{professional.conta_pix || "-"}</span>
</div>
<div className="bg-gray-50 p-3 rounded-lg border border-gray-100">
<span className="block text-gray-500 text-xs uppercase tracking-wider font-semibold mb-1">Banco / Agência</span>
<span className="font-medium text-gray-900">
{professional.banco || "-"}{professional.agencia ? ` / ${professional.agencia}` : ""}
</span>
</div>
<div className="bg-gray-50 p-3 rounded-lg border border-gray-100">
<span className="block text-gray-500 text-xs uppercase tracking-wider font-semibold mb-1">Tabela Free</span>
<span className="font-medium text-gray-900">R$ {professional.tabela_free || "0,00"}</span>
</div>
<div className="bg-gray-50 p-3 rounded-lg border border-gray-100">
<span className="block text-gray-500 text-xs uppercase tracking-wider font-semibold mb-1">Tipo de Cartão</span>
<span className="font-medium text-gray-900">{professional.tipo_cartao || "-"}</span>
</div>
</div>
</div>
{/* Performance / Observations */}
<div className="w-full mt-8 bg-brand-gold/5 rounded-xl p-6 border border-brand-gold/10">
<div className="flex items-start gap-4">
<div className="p-2 bg-white rounded-full text-brand-gold shadow-sm">
<Star size={24} className="text-brand-gold fill-brand-gold" />
</div>
<div className="w-full">
<h4 className="text-lg font-bold text-gray-900 mb-4">Performance & Avaliação</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<div className="text-center bg-white p-2 rounded shadow-sm">
<div className="text-xs text-gray-500 mb-1">Técnica</div>
<div className="font-bold text-lg text-gray-800">{professional.qual_tec || 0}</div>
{/* Dados Financeiros & Performance - Protected */}
{isAdminOrOwner && (
<>
<div className="w-full mt-8">
<h3 className="text-lg font-bold text-gray-900 flex items-center gap-2 mb-4 border-b pb-2">
<DollarSign size={20} className="text-brand-gold" />
Dados Financeiros
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm w-full">
<div className="bg-gray-50 p-3 rounded-lg border border-gray-100">
<span className="block text-gray-500 text-xs uppercase tracking-wider font-semibold mb-1">CPF/CNPJ Titular</span>
<span className="font-medium text-gray-900">{professional.cpf_cnpj_titular || "-"}</span>
</div>
<div className="text-center bg-white p-2 rounded shadow-sm">
<div className="text-xs text-gray-500 mb-1">Simpatia</div>
<div className="font-bold text-lg text-gray-800">{professional.educacao_simpatia || 0}</div>
<div className="bg-gray-50 p-3 rounded-lg border border-gray-100">
<span className="block text-gray-500 text-xs uppercase tracking-wider font-semibold mb-1">Chave Pix</span>
<span className="font-medium text-gray-900">{professional.conta_pix || "-"}</span>
</div>
<div className="text-center bg-white p-2 rounded shadow-sm">
<div className="text-xs text-gray-500 mb-1">Desempenho</div>
<div className="font-bold text-lg text-gray-800">{professional.desempenho_evento || 0}</div>
<div className="bg-gray-50 p-3 rounded-lg border border-gray-100">
<span className="block text-gray-500 text-xs uppercase tracking-wider font-semibold mb-1">Banco / Agência</span>
<span className="font-medium text-gray-900">
{professional.banco || "-"}{professional.agencia ? ` / ${professional.agencia}` : ""}
</span>
</div>
<div className="text-center bg-white p-2 rounded shadow-sm">
<div className="text-xs text-gray-500 mb-1">Horário</div>
<div className="font-bold text-lg text-gray-800">{professional.disp_horario || 0}</div>
<div className="bg-gray-50 p-3 rounded-lg border border-gray-100">
<span className="block text-gray-500 text-xs uppercase tracking-wider font-semibold mb-1">Tabela Free</span>
<span className="font-medium text-gray-900">R$ {professional.tabela_free || "0,00"}</span>
</div>
<div className="bg-gray-50 p-3 rounded-lg border border-gray-100">
<span className="block text-gray-500 text-xs uppercase tracking-wider font-semibold mb-1">Tipo de Cartão</span>
<span className="font-medium text-gray-900">{professional.tipo_cartao || "-"}</span>
</div>
</div>
</div>
</>
)}
<p className="text-gray-600 text-sm leading-relaxed mb-2">
Média Geral: <strong>{professional.media ? (typeof professional.media === 'number' ? professional.media.toFixed(1) : parseFloat(String(professional.media)).toFixed(1)) : "N/A"}</strong>
</p>
{professional.observacao && (
<div className="mt-3 text-sm text-gray-500 italic border-t border-brand-gold/10 pt-2">
"{professional.observacao}"
{/* Performance / Observations - Protected */}
{isAdminOrOwner && (
<div className="w-full mt-8 bg-brand-gold/5 rounded-xl p-6 border border-brand-gold/10">
<div className="flex items-start gap-4">
<div className="p-2 bg-white rounded-full text-brand-gold shadow-sm">
<Star size={24} className="text-brand-gold fill-brand-gold" />
</div>
<div className="w-full">
<h4 className="text-lg font-bold text-gray-900 mb-4">Performance & Avaliação</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<div className="text-center bg-white p-2 rounded shadow-sm">
<div className="text-xs text-gray-500 mb-1">Técnica</div>
<div className="font-bold text-lg text-gray-800">{professional.qual_tec || 0}</div>
</div>
<div className="text-center bg-white p-2 rounded shadow-sm">
<div className="text-xs text-gray-500 mb-1">Simpatia</div>
<div className="font-bold text-lg text-gray-800">{professional.educacao_simpatia || 0}</div>
</div>
<div className="text-center bg-white p-2 rounded shadow-sm">
<div className="text-xs text-gray-500 mb-1">Desempenho</div>
<div className="font-bold text-lg text-gray-800">{professional.desempenho_evento || 0}</div>
</div>
<div className="text-center bg-white p-2 rounded shadow-sm">
<div className="text-xs text-gray-500 mb-1">Horário</div>
<div className="font-bold text-lg text-gray-800">{professional.disp_horario || 0}</div>
</div>
</div>
)}
<p className="text-gray-600 text-sm leading-relaxed mb-2">
Média Geral: <strong>{professional.media ? (typeof professional.media === 'number' ? professional.media.toFixed(1) : parseFloat(String(professional.media)).toFixed(1)) : "N/A"}</strong>
</p>
{professional.observacao && (
<div className="mt-3 text-sm text-gray-500 italic border-t border-brand-gold/10 pt-2">
"{professional.observacao}"
</div>
)}
</div>
</div>
</div>
</div>
)}
<div className="w-full mt-8 flex justify-end">
{/* Assignments - Protected */}
{canViewDetails && (
<div className="w-full mt-8">
<h3 className="text-lg font-bold text-gray-900 flex items-center gap-2 mb-4 border-b pb-2">
<Calendar size={20} className="text-brand-gold" />
Eventos Atribuídos
</h3>
{loadingEvents ? (
<p className="text-sm text-gray-500">Carregando agenda...</p>
) : assignedEvents.length === 0 ? (
<p className="text-sm text-gray-500 italic">Nenhum evento atribuído.</p>
) : (
<div className="space-y-3">
{assignedEvents.map((evt: any) => (
<div key={evt.id} className="bg-white border rounded-lg p-3 flex justify-between items-center shadow-sm">
<div>
<p className="font-bold text-gray-800 text-sm">{evt.empresa_nome || "Empresa"} - {evt.tipo_evento_nome || "Evento"}</p>
<div className="flex gap-3 text-xs text-gray-500 mt-1">
<span className="flex items-center gap-1"><Calendar size={12} /> {new Date(evt.data_evento).toLocaleDateString()}</span>
<span className="flex items-center gap-1"><Clock size={12} /> {evt.horario}</span>
{evt.local_evento && <span className="flex items-center gap-1"><MapPin size={12} /> {evt.local_evento}</span>}
</div>
</div>
<div className="text-right">
<span className={`text-xs px-2 py-1 rounded-full ${evt.status === 'Confirmado' ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'}`}>
{evt.status || "Agendado"}
</span>
</div>
</div>
))}
</div>
)}
</div>
)}
<div className="w-full mt-8 flex justify-end gap-3">
<Button variant="outline" onClick={onClose}>
Fechar
</Button>
{onEdit && (
<Button onClick={onEdit}>
<Edit2 size={16} className="mr-2" />
Editar Dados
</Button>
)}
</div>
</div>

View file

@ -1,4 +1,5 @@
import React, { useState, useEffect, useMemo } from "react";
import { useNavigate } from 'react-router-dom';
import { UserRole, EventData, EventStatus, EventType, Professional } from "../types";
import { EventTable } from "../components/EventTable";
import { EventFiltersBar, EventFilters } from "../components/EventFiltersBar";
@ -32,6 +33,7 @@ export const Dashboard: React.FC<DashboardProps> = ({
initialView = "list",
}) => {
const { user } = useAuth();
const navigate = useNavigate();
const {
events,
getEventsByRole,
@ -396,8 +398,15 @@ export const Dashboard: React.FC<DashboardProps> = ({
</div>
<div className="p-6">
{/* Actions Toolbar */}
<div className="flex flex-wrap gap-2 mb-6 pb-4 border-b">
<Button
variant="primary"
onClick={() => navigate(`/agenda/${selectedEvent.id}`)}
className="text-sm bg-brand-purple text-white hover:bg-brand-purple/90"
>
<CheckCircle size={16} className="mr-2" /> Área Operacional
</Button>
{(user.role === UserRole.BUSINESS_OWNER ||
user.role === UserRole.SUPERADMIN) && (
<>
@ -434,6 +443,24 @@ export const Dashboard: React.FC<DashboardProps> = ({
>
<Map size={16} className="mr-2" /> Abrir no Maps
</Button>
<Button
variant="default"
onClick={() => {
// Use a direct window location or navigate if available.
// Since Dashboard doesn't seem to expose navigate cleanly, likely we need to pass it or use window.location
// Actually, Dashboard is usually wrapped in a Router context.
// We can use window.open or window.location.href for now as Dashboard props don't include navigation function easily accessible without hooking.
// However, line 348 uses setView("list"). Ideally we use useNavigate.
// Looking at lines 34-45, we don't have navigate.
// But Dashboard is inside PageWrapper which has navigate? No.
// Let's use window.location.assign for simplicity or add useNavigate.
window.location.assign(`/agenda/${selectedEvent.id}`);
}}
className="text-sm bg-brand-purple text-white hover:bg-brand-purple/90"
>
<CheckCircle size={16} className="mr-2" /> Área Operacional
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
@ -878,8 +905,14 @@ export const Dashboard: React.FC<DashboardProps> = ({
);
const status = assignment ? assignment.status : null;
const isAssigned = !!status && status !== "REJEITADO"; // Consider assigned if not rejected (effectively)
const isAvailable = true;
const isAssigned = !!status && status !== "REJEITADO";
// Check if busy in other events on the same date
const isBusy = !isAssigned && events.some(e =>
e.id !== selectedEvent.id &&
e.date === selectedEvent.date &&
e.photographerIds.includes(photographer.id)
);
return (
<tr
@ -940,9 +973,9 @@ export const Dashboard: React.FC<DashboardProps> = ({
</span>
)}
{!status && (
<span className="inline-flex items-center gap-1.5 px-3 py-1 bg-gray-100 text-gray-600 rounded-full text-xs font-medium">
<UserCheck size={14} />
Disponível
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium ${isBusy ? "bg-red-100 text-red-800" : "bg-gray-100 text-gray-600"}`}>
{isBusy ? <UserX size={14} /> : <UserCheck size={14} />}
{isBusy ? "Em outro evento" : "Disponível"}
</span>
)}
</td>
@ -953,7 +986,7 @@ export const Dashboard: React.FC<DashboardProps> = ({
onClick={() =>
togglePhotographer(photographer.id)
}
disabled={false}
disabled={!status && isBusy}
className={`px-4 py-2 rounded-lg font-medium text-sm transition-colors ${status === "ACEITO" || status === "PENDENTE"
? "bg-red-100 text-red-700 hover:bg-red-200"
: "bg-brand-gold text-white hover:bg-[#a5bd2e]"
@ -993,6 +1026,13 @@ export const Dashboard: React.FC<DashboardProps> = ({
);
const status = assignment ? assignment.status : null;
// Check if busy in other events on the same date
const isBusy = !status && events.some(e =>
e.id !== selectedEvent.id &&
e.date === selectedEvent.date &&
e.photographerIds.includes(photographer.id)
);
return (
<div key={photographer.id} className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm">
<div className="flex items-center gap-3 mb-3" onClick={() => handleViewProfessional(photographer)}>
@ -1030,8 +1070,9 @@ export const Dashboard: React.FC<DashboardProps> = ({
</span>
)}
{!status && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-gray-100 text-gray-600 rounded-full text-xs font-medium">
<UserCheck size={12} /> Disponível
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${isBusy ? "bg-red-100 text-red-800" : "bg-gray-100 text-gray-600"}`}>
{isBusy ? <UserX size={12} /> : <UserCheck size={12} />}
{isBusy ? "Em outro evento" : "Disponível"}
</span>
)}
</div>
@ -1045,12 +1086,13 @@ export const Dashboard: React.FC<DashboardProps> = ({
</button>
<button
onClick={() => togglePhotographer(photographer.id)}
disabled={!status && isBusy}
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-colors ${status === "ACEITO" || status === "PENDENTE"
? "bg-red-100 text-red-700 hover:bg-red-200"
: "bg-brand-gold text-white hover:bg-[#a5bd2e]"
: isBusy ? "bg-gray-300 text-gray-500 cursor-not-allowed" : "bg-brand-gold text-white hover:bg-[#a5bd2e]"
}`}
>
{status === "ACEITO" || status === "PENDENTE" ? "Remover" : "Adicionar"}
{status === "ACEITO" || status === "PENDENTE" ? "Remover" : isBusy ? "Ocupado" : "Adicionar"}
</button>
</div>
</div>

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

View file

@ -37,6 +37,7 @@ import {
} from "../services/apiService";
import { useAuth } from "../contexts/AuthContext";
import { Professional, CreateProfessionalDTO } from "../types";
import { ProfessionalDetailsModal } from "../components/ProfessionalDetailsModal";
export const TeamPage: React.FC = () => {
const { user, token: contextToken } = useAuth();
@ -821,205 +822,16 @@ export const TeamPage: React.FC = () => {
{/* View Modal */}
{viewProfessional && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 animate-fadeIn">
<div className="bg-white rounded-xl max-w-2xl w-full p-0 overflow-hidden shadow-2xl max-h-[90vh] overflow-y-auto animate-slideIn">
{/* Header / Avatar Section */}
<div className="relative pt-12 pb-6 px-8 text-center bg-gradient-to-b from-gray-50 to-white">
<button
onClick={() => setViewProfessional(null)}
className="absolute top-4 right-4 text-gray-400 hover:text-gray-600 transition-colors"
>
<X size={24} />
</button>
<div className="relative inline-block mb-4">
<div className="w-32 h-32 rounded-full p-[3px] bg-gradient-to-tr from-brand-gold to-purple-600">
<img
className="w-full h-full rounded-full object-cover border-4 border-white"
src={viewProfessional.avatar_url || viewProfessional.avatar || GenericAvatar}
alt={viewProfessional.nome}
/>
</div>
</div>
<h2 className="text-3xl font-serif font-bold text-gray-900 mb-2">{viewProfessional.nome}</h2>
<div className="flex justify-center items-center gap-3">
<span className="px-3 py-1 rounded-full bg-brand-gold/10 text-brand-gold-dark text-sm font-semibold border border-brand-gold/20 flex items-center gap-2">
{(() => {
const RoleIcon = getRoleIcon(getRoleName(viewProfessional.funcao_profissional_id));
return <RoleIcon size={14} />;
})()}
{getRoleName(viewProfessional.funcao_profissional_id)}
</span>
{viewProfessional.media !== undefined && viewProfessional.media !== null && (
<span className="px-3 py-1 rounded-full bg-yellow-100 text-yellow-700 text-sm font-semibold border border-yellow-200 flex items-center gap-1">
<Star size={14} className="fill-current" />
{typeof viewProfessional.media === 'number' ? viewProfessional.media.toFixed(1) : parseFloat(viewProfessional.media).toFixed(1)}
</span>
)}
</div>
</div>
<div className="p-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* Personal Data */}
<div>
<h3 className="text-lg font-bold text-gray-900 flex items-center gap-2 mb-4 border-b pb-2">
<User size={20} className="text-brand-gold" />
Dados Pessoais
</h3>
<div className="space-y-4">
{viewProfessional.email && (
<div className="flex items-start gap-3 text-gray-600">
<Mail size={18} className="mt-1 shrink-0 text-gray-400" />
<span className="break-all">{viewProfessional.email}</span>
</div>
)}
{viewProfessional.whatsapp && (
<div className="flex items-start gap-3 text-gray-600">
<Phone size={18} className="mt-1 shrink-0 text-gray-400" />
<span>{viewProfessional.whatsapp}</span>
</div>
)}
{(viewProfessional.cidade || viewProfessional.uf) && (
<div className="flex items-start gap-3 text-gray-600">
<MapPin size={18} className="mt-1 shrink-0 text-gray-400" />
<span>{viewProfessional.cidade}{viewProfessional.cidade && viewProfessional.uf ? ", " : ""}{viewProfessional.uf}</span>
</div>
)}
{viewProfessional.endereco && (
<div className="flex items-start gap-3 text-gray-600">
<Building size={18} className="mt-1 shrink-0 text-gray-400" />
<span className="text-sm">{viewProfessional.endereco}</span>
</div>
)}
</div>
</div>
{/* Equipment & Skills */}
<div>
<h3 className="text-lg font-bold text-gray-900 flex items-center gap-2 mb-4 border-b pb-2">
<Camera size={20} className="text-brand-gold" />
Equipamentos
</h3>
<div className="bg-gray-50 rounded-lg p-4 text-sm text-gray-700 whitespace-pre-wrap leading-relaxed border border-gray-100">
{viewProfessional.equipamentos || "Nenhum equipamento listado."}
</div>
<div className="mt-4 flex flex-wrap gap-2">
{viewProfessional.carro_disponivel && (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
<Car size={12} className="mr-1" /> Carro Próprio
</span>
)}
{viewProfessional.tem_estudio && (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
<Building size={12} className="mr-1" /> Estúdio ({viewProfessional.qtd_estudio})
</span>
)}
{viewProfessional.extra_por_equipamento && (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
<CreditCard size={12} className="mr-1" /> Extra p/ Equip.
</span>
)}
</div>
</div>
</div>
{/* Performance / Observations */}
{/* Financial Data */}
<div className="mt-8">
<h3 className="text-lg font-bold text-gray-900 flex items-center gap-2 mb-4 border-b pb-2">
<DollarSign size={20} className="text-brand-gold" />
Dados Financeiros
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div className="bg-gray-50 p-3 rounded-lg border border-gray-100">
<span className="block text-gray-500 text-xs uppercase tracking-wider font-semibold mb-1">CPF/CNPJ Titular</span>
<span className="font-medium text-gray-900">{viewProfessional.cpf_cnpj_titular || "-"}</span>
</div>
<div className="bg-gray-50 p-3 rounded-lg border border-gray-100">
<span className="block text-gray-500 text-xs uppercase tracking-wider font-semibold mb-1">Chave Pix</span>
<span className="font-medium text-gray-900">{viewProfessional.conta_pix || "-"}</span>
</div>
<div className="bg-gray-50 p-3 rounded-lg border border-gray-100">
<span className="block text-gray-500 text-xs uppercase tracking-wider font-semibold mb-1">Banco / Agência</span>
<span className="font-medium text-gray-900">
{viewProfessional.banco || "-"}{viewProfessional.agencia ? ` / ${viewProfessional.agencia}` : ""}
</span>
</div>
<div className="bg-gray-50 p-3 rounded-lg border border-gray-100">
<span className="block text-gray-500 text-xs uppercase tracking-wider font-semibold mb-1">Tabela Free</span>
<span className="font-medium text-gray-900">R$ {viewProfessional.tabela_free || "0,00"}</span>
</div>
<div className="bg-gray-50 p-3 rounded-lg border border-gray-100">
<span className="block text-gray-500 text-xs uppercase tracking-wider font-semibold mb-1">Tipo de Cartão</span>
<span className="font-medium text-gray-900">{viewProfessional.tipo_cartao || "-"}</span>
</div>
</div>
</div>
{/* Performance / Observations */}
<div className="mt-8 bg-brand-gold/5 rounded-xl p-6 border border-brand-gold/10">
<div className="flex items-start gap-4">
<div className="p-2 bg-white rounded-full text-brand-gold shadow-sm">
<Star size={24} className="text-brand-gold fill-brand-gold" />
</div>
<div className="w-full">
<h4 className="text-lg font-bold text-gray-900 mb-4">Performance & Avaliação</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<div className="text-center bg-white p-2 rounded shadow-sm">
<div className="text-xs text-gray-500 mb-1">Técnica</div>
<div className="font-bold text-lg text-gray-800">{viewProfessional.qual_tec || 0}</div>
</div>
<div className="text-center bg-white p-2 rounded shadow-sm">
<div className="text-xs text-gray-500 mb-1">Simpatia</div>
<div className="font-bold text-lg text-gray-800">{viewProfessional.educacao_simpatia || 0}</div>
</div>
<div className="text-center bg-white p-2 rounded shadow-sm">
<div className="text-xs text-gray-500 mb-1">Desempenho</div>
<div className="font-bold text-lg text-gray-800">{viewProfessional.desempenho_evento || 0}</div>
</div>
<div className="text-center bg-white p-2 rounded shadow-sm">
<div className="text-xs text-gray-500 mb-1">Horário</div>
<div className="font-bold text-lg text-gray-800">{viewProfessional.disp_horario || 0}</div>
</div>
</div>
<p className="text-gray-600 text-sm leading-relaxed mb-2">
Média Geral: <strong>{viewProfessional.media ? (typeof viewProfessional.media === 'number' ? viewProfessional.media.toFixed(1) : parseFloat(viewProfessional.media).toFixed(1)) : "N/A"}</strong>
</p>
{viewProfessional.observacao && (
<div className="mt-3 text-sm text-gray-500 italic border-t border-brand-gold/10 pt-2">
"{viewProfessional.observacao}"
</div>
)}
</div>
</div>
</div>
</div>
{/* Footer */}
<div className="p-6 bg-gray-50 border-t border-gray-100 flex justify-end gap-3">
<Button
variant="secondary"
onClick={() => setViewProfessional(null)}
>
Fechar
</Button>
<Button
onClick={() => {
setViewProfessional(null);
handleEditClick(viewProfessional);
}}
>
<Edit2 size={16} className="mr-2" />
Editar Dados
</Button>
</div>
</div>
</div>
<ProfessionalDetailsModal
professional={viewProfessional}
isOpen={!!viewProfessional}
onClose={() => setViewProfessional(null)}
onEdit={() => {
const p = viewProfessional;
setViewProfessional(null);
handleEditClick(p);
}}
/>
)}
</div>

View file

@ -838,3 +838,173 @@ export async function uploadFileToSignedUrl(uploadUrl: string, file: File): Prom
throw new Error(`Failed to upload file to S3. Status: ${response.status}`);
}
}
// --- Escalas / Scheduling ---
export interface EscalaInput {
agenda_id: string;
profissional_id: string;
data_hora_inicio: string; // ISO String
data_hora_fim: string; // ISO String
funcao_especifica?: string;
}
export async function createEscala(data: EscalaInput, token: string): Promise<ApiResponse<{ id: string }>> {
try {
const response = await fetch(`${API_BASE_URL}/api/escalas`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
body: JSON.stringify(data),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
}
const responseData = await response.json();
return { data: responseData, error: null, isBackendDown: false };
} catch (error) {
return { data: null, error: error instanceof Error ? error.message : "Erro desconhecido", isBackendDown: true };
}
}
export async function listEscalas(agendaId: string, token: string): Promise<ApiResponse<any[]>> {
try {
const response = await fetch(`${API_BASE_URL}/api/escalas?agenda_id=${agendaId}`, {
headers: { "Authorization": `Bearer ${token}` },
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
}
const responseData = await response.json();
return { data: responseData, error: null, isBackendDown: false };
} catch (error) {
return { data: null, error: error instanceof Error ? error.message : "Erro desconhecido", isBackendDown: true };
}
}
export async function deleteEscala(id: string, token: string): Promise<ApiResponse<any>> {
try {
const response = await fetch(`${API_BASE_URL}/api/escalas/${id}`, {
method: "DELETE",
headers: { "Authorization": `Bearer ${token}` },
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
}
return { data: { message: "deleted" }, error: null, isBackendDown: false };
} catch (error) {
return { data: null, error: error instanceof Error ? error.message : "Erro desconhecido", isBackendDown: true };
}
}
export async function updateEscala(id: string, data: Partial<EscalaInput>, token: string): Promise<ApiResponse<any>> {
try {
const response = await fetch(`${API_BASE_URL}/api/escalas/${id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
body: JSON.stringify(data),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
}
return { data: { message: "updated" }, error: null, isBackendDown: false };
} catch (error) {
return { data: null, error: error instanceof Error ? error.message : "Erro desconhecido", isBackendDown: true };
}
}
// --- Logistica (Carros/Passageiros) ---
export interface CarroInput {
agenda_id: string;
motorista_id?: string;
nome_motorista?: string;
horario_chegada?: string;
observacoes?: string;
}
export async function createCarro(data: CarroInput, token: string): Promise<ApiResponse<{ id: string }>> {
try {
const response = await fetch(`${API_BASE_URL}/api/logistica/carros`, {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
body: JSON.stringify(data),
});
const resData = await response.json();
if (!response.ok) throw new Error(resData.error || "Erro ao criar carro");
return { data: resData, error: null, isBackendDown: false };
} catch (err: any) { return { data: null, error: err.message, isBackendDown: true }; }
}
export async function listCarros(agendaId: string, token: string): Promise<ApiResponse<any[]>> {
try {
const response = await fetch(`${API_BASE_URL}/api/logistica/carros?agenda_id=${agendaId}`, {
headers: { "Authorization": `Bearer ${token}` },
});
const resData = await response.json();
if (!response.ok) throw new Error(resData.error || "Erro ao listar carros");
return { data: resData, error: null, isBackendDown: false };
} catch (err: any) { return { data: null, error: err.message, isBackendDown: true }; }
}
export async function deleteCarro(id: string, token: string): Promise<ApiResponse<any>> {
try {
const response = await fetch(`${API_BASE_URL}/api/logistica/carros/${id}`, {
method: "DELETE",
headers: { "Authorization": `Bearer ${token}` },
});
if (!response.ok) throw new Error("Erro ao deletar carro");
return { data: { message: "deleted" }, error: null, isBackendDown: false };
} catch (err: any) { return { data: null, error: err.message, isBackendDown: true }; }
}
export async function addPassenger(carroId: string, profissionalId: string, token: string) {
try {
const response = await fetch(`${API_BASE_URL}/api/logistica/carros/${carroId}/passageiros`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ profissional_id: profissionalId })
});
if (!response.ok) throw new Error("Erro ao adicionar passageiro");
return { error: null };
} catch (err: any) { return { error: err.message }; }
}
export async function removePassenger(carroId: string, profissionalId: string, token: string) {
try {
const response = await fetch(`${API_BASE_URL}/api/logistica/carros/${carroId}/passageiros/${profissionalId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) throw new Error("Erro ao remover passageiro");
return { error: null };
} catch (err: any) { return { error: err.message }; }
}
export async function listPassengers(carroId: string, token: string) {
try {
const response = await fetch(`${API_BASE_URL}/api/logistica/carros/${carroId}/passageiros`, {
headers: { 'Authorization': `Bearer ${token}` }
});
const data = await response.json();
if (!response.ok) throw new Error("Erro ao listar passageiros");
return { data, error: null };
} catch (err: any) { return { data: null, error: err.message }; }
}

View file

@ -140,6 +140,7 @@ export interface EventData {
empresa?: string; // Nome da Empresa
observacoes?: string; // Observações da FOT
tipoEventoNome?: string; // Nome do Tipo de Evento
local_evento?: string; // Nome do Local (Salão, Igreja, etc)
assignments?: Assignment[]; // Lista de status de atribuições
}