diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 3924ce6..40d0c63 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -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) diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 241c0ce..b70af06 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -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": [ diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index b83d61a..7772867 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -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": [ diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 7466503..59d6cfb 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -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: diff --git a/backend/internal/db/generated/escalas.sql.go b/backend/internal/db/generated/escalas.sql.go new file mode 100644 index 0000000..731d04a --- /dev/null +++ b/backend/internal/db/generated/escalas.sql.go @@ -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 +} diff --git a/backend/internal/db/generated/logistica.sql.go b/backend/internal/db/generated/logistica.sql.go new file mode 100644 index 0000000..f7822bc --- /dev/null +++ b/backend/internal/db/generated/logistica.sql.go @@ -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 +} diff --git a/backend/internal/db/generated/models.go b/backend/internal/db/generated/models.go index b7a48c5..8566f04 100644 --- a/backend/internal/db/generated/models.go +++ b/backend/internal/db/generated/models.go @@ -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"` diff --git a/backend/internal/db/queries/escalas.sql b/backend/internal/db/queries/escalas.sql new file mode 100644 index 0000000..88a331d --- /dev/null +++ b/backend/internal/db/queries/escalas.sql @@ -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; diff --git a/backend/internal/db/queries/logistica.sql b/backend/internal/db/queries/logistica.sql new file mode 100644 index 0000000..c8ce3e8 --- /dev/null +++ b/backend/internal/db/queries/logistica.sql @@ -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; diff --git a/backend/internal/db/schema.sql b/backend/internal/db/schema.sql index 606735e..a82fa15 100644 --- a/backend/internal/db/schema.sql +++ b/backend/internal/db/schema.sql @@ -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) +); diff --git a/backend/internal/escalas/handler.go b/backend/internal/escalas/handler.go new file mode 100644 index 0000000..5930ff3 --- /dev/null +++ b/backend/internal/escalas/handler.go @@ -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"}) +} diff --git a/backend/internal/escalas/service.go b/backend/internal/escalas/service.go new file mode 100644 index 0000000..be33e28 --- /dev/null +++ b/backend/internal/escalas/service.go @@ -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 +} diff --git a/backend/internal/logistica/handler.go b/backend/internal/logistica/handler.go new file mode 100644 index 0000000..8890aff --- /dev/null +++ b/backend/internal/logistica/handler.go @@ -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) +} diff --git a/backend/internal/logistica/service.go b/backend/internal/logistica/service.go new file mode 100644 index 0000000..8a2dd6f --- /dev/null +++ b/backend/internal/logistica/service.go @@ -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}) +} diff --git a/frontend/App.tsx b/frontend/App.tsx index 6bf5078..a46d7cd 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -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 = () => { } /> + + + + + + } + /> + {/* Rota de solicitação de evento - Clientes e Administradores */} { allowedRoles={[UserRole.SUPERADMIN, UserRole.BUSINESS_OWNER]} > - {}} /> + { }} /> } diff --git a/frontend/components/EventLogistics.tsx b/frontend/components/EventLogistics.tsx new file mode 100644 index 0000000..d9afe9d --- /dev/null +++ b/frontend/components/EventLogistics.tsx @@ -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 = ({ agendaId, assignedProfessionals }) => { + const { token, user } = useAuth(); + const { professionals } = useData(); + const [carros, setCarros] = useState([]); + 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 ( +
+

+ + Logística de Transporte +

+ + {/* Add Car Form - Only for Admins */} + {isEditable && ( +
+
+ + +
+
+ + setArrivalTime(e.target.value)} + /> +
+ +
+ )} + + {/* Cars List */} +
+ {loading ?

Carregando...

: carros.map(car => ( +
+
+
+
+ +
+
+

+ {car.driver_name || "Motorista não definido"} +

+

Chegada: {car.arrival_time}

+
+
+ {isEditable && ( + + )} +
+ + {/* Passengers */} +
+

Passageiros

+ {car.passengers.length === 0 &&

Vazio

} + {car.passengers.map((p: any) => ( +
+ {p.name || "Desconhecido"} + {isEditable && ( + + )} +
+ ))} +
+ + {/* Add Passenger - Only for Admins */} + {isEditable && ( + + )} +
+ ))} +
+
+ ); +}; + +export default EventLogistics; diff --git a/frontend/components/EventScheduler.tsx b/frontend/components/EventScheduler.tsx new file mode 100644 index 0000000..fa3c316 --- /dev/null +++ b/frontend/components/EventScheduler.tsx @@ -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 = ({ agendaId, dataEvento, allowedProfessionals, onUpdateStats, defaultTime }) => { + const { token, user } = useAuth(); + const { professionals, events } = useData(); + const [escalas, setEscalas] = useState([]); + 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 ( +
+

+ + Escala de Profissionais +

+ + {/* Warning if restricting and empty */} + {isEditable && allowedProfessionals && allowedProfessionals.length === 0 && ( +
+ Nenhum profissional atribuído a este evento. Adicione membros à equipe antes de criar a escala. +
+ )} + + {/* Add Form - Only for Admins */} + {isEditable && ( +
+
+
+ + +
+
+ + setStartTime(e.target.value)} + /> +
+ +
+ + setRole(e.target.value)} + /> +
+ +
+ + {/* Equipment Info Preview */} + {selectedProfessionalData && (selectedProfessionalData.equipamentos || selectedProfessionalData.qtd_estudio > 0) && ( +
+ Equipamentos: {selectedProfessionalData.equipamentos || "Nenhum cadastrado"} + {selectedProfessionalData.qtd_estudio > 0 && ( + • Possui {selectedProfessionalData.qtd_estudio} Estúdio(s) + )} +
+ )} +
+ )} + + {/* Timeline / List */} +
+ {loading ?

Carregando...

: escalas.length === 0 ? ( +

Nenhuma escala definida.

+ ) : ( + 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 ( +
+
+
+
+ {item.avatar_url ? ( + + ) : ( + + )} +
+
+

+ {item.profissional_nome} + {item.phone && ({item.phone})} +

+

+ {new Date(item.start).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - + {new Date(item.end).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + {item.role && {item.role}} +

+
+
+ {isEditable && ( + + )} +
+ {/* Show equipment if available */} + {profData && profData.equipamentos && ( +
+ Equip: {profData.equipamentos} +
+ )} +
+ ); + }) + )} +
+
+ ); +}; + +export default EventScheduler; diff --git a/frontend/components/ProfessionalDetailsModal.tsx b/frontend/components/ProfessionalDetailsModal.tsx index 0ebb170..3db3e47 100644 --- a/frontend/components/ProfessionalDetailsModal.tsx +++ b/frontend/components/ProfessionalDetailsModal.tsx @@ -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 = ({ professional, isOpen, onClose, + onEdit }) => { + const { user, token } = useAuth(); + const [assignedEvents, setAssignedEvents] = React.useState([]); + 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 (
- - {/* Header com Capa/Avatar Style */} + {/* Header... (remains same) */}
- {/* Conteúdo Principal */}
- - {/* Avatar Grande */} + {/* Avatar... (remains same) */}
= {professional.role || "Profissional"} - {professional.media !== undefined && professional.media !== null && ( + {/* Performance Rating - Only for Admins */} + {isAdminOrOwner && professional.media !== undefined && professional.media !== null && ( {typeof professional.media === 'number' ? professional.media.toFixed(1) : parseFloat(String(professional.media)).toFixed(1)} @@ -63,43 +102,49 @@ export const ProfessionalDetailsModal: React.FC =
- - {/* Dados Pessoais */} + {/* Dados Pessoais - Protected */}

Dados Pessoais

-
- {professional.email && ( -
- - {professional.email} -
- )} - {professional.whatsapp && ( -
- - {professional.whatsapp} -
- )} - {(professional.cidade || professional.uf) && ( -
- - {professional.cidade}{professional.cidade && professional.uf ? ", " : ""}{professional.uf} -
- )} - {professional.endereco && ( -
- - {professional.endereco} -
- )} -
+ {isAdminOrOwner ? ( +
+ {professional.email && ( +
+ + {professional.email} +
+ )} + {professional.whatsapp && ( +
+ + {professional.whatsapp} +
+ )} + {(professional.cidade || professional.uf) && ( +
+ + {professional.cidade}{professional.cidade && professional.uf ? ", " : ""}{professional.uf} +
+ )} + {professional.endereco && ( +
+ + {professional.endereco} +
+ )} +
+ ) : ( +
+ +

Informações de contato restritas.

+
+ )}
- {/* Equipamentos */} + {/* Equipamentos - Public */}

@@ -130,82 +175,129 @@ export const ProfessionalDetailsModal: React.FC =

- {/* Dados Financeiros */} -
-

- - Dados Financeiros -

-
-
- CPF/CNPJ Titular - {professional.cpf_cnpj_titular || "-"} -
-
- Chave Pix - {professional.conta_pix || "-"} -
-
- Banco / Agência - - {professional.banco || "-"}{professional.agencia ? ` / ${professional.agencia}` : ""} - -
-
- Tabela Free - R$ {professional.tabela_free || "0,00"} -
-
- Tipo de Cartão - {professional.tipo_cartao || "-"} -
-
-
- - {/* Performance / Observations */} -
-
-
- -
-
-

Performance & Avaliação

- -
-
-
Técnica
-
{professional.qual_tec || 0}
+ {/* Dados Financeiros & Performance - Protected */} + {isAdminOrOwner && ( + <> +
+

+ + Dados Financeiros +

+
+
+ CPF/CNPJ Titular + {professional.cpf_cnpj_titular || "-"}
-
-
Simpatia
-
{professional.educacao_simpatia || 0}
+
+ Chave Pix + {professional.conta_pix || "-"}
-
-
Desempenho
-
{professional.desempenho_evento || 0}
+
+ Banco / Agência + + {professional.banco || "-"}{professional.agencia ? ` / ${professional.agencia}` : ""} +
-
-
Horário
-
{professional.disp_horario || 0}
+
+ Tabela Free + R$ {professional.tabela_free || "0,00"} +
+
+ Tipo de Cartão + {professional.tipo_cartao || "-"}
+
+ + )} -

- Média Geral: {professional.media ? (typeof professional.media === 'number' ? professional.media.toFixed(1) : parseFloat(String(professional.media)).toFixed(1)) : "N/A"} -

- {professional.observacao && ( -
- "{professional.observacao}" + {/* Performance / Observations - Protected */} + {isAdminOrOwner && ( +
+
+
+ +
+
+

Performance & Avaliação

+ +
+
+
Técnica
+
{professional.qual_tec || 0}
+
+
+
Simpatia
+
{professional.educacao_simpatia || 0}
+
+
+
Desempenho
+
{professional.desempenho_evento || 0}
+
+
+
Horário
+
{professional.disp_horario || 0}
+
- )} + +

+ Média Geral: {professional.media ? (typeof professional.media === 'number' ? professional.media.toFixed(1) : parseFloat(String(professional.media)).toFixed(1)) : "N/A"} +

+ {professional.observacao && ( +
+ "{professional.observacao}" +
+ )} +
-
+ )} -
+ {/* Assignments - Protected */} + {canViewDetails && ( +
+

+ + Eventos Atribuídos +

+ {loadingEvents ? ( +

Carregando agenda...

+ ) : assignedEvents.length === 0 ? ( +

Nenhum evento atribuído.

+ ) : ( +
+ {assignedEvents.map((evt: any) => ( +
+
+

{evt.empresa_nome || "Empresa"} - {evt.tipo_evento_nome || "Evento"}

+
+ {new Date(evt.data_evento).toLocaleDateString()} + {evt.horario} + {evt.local_evento && {evt.local_evento}} +
+
+
+ + {evt.status || "Agendado"} + +
+
+ ))} +
+ )} +
+ )} + +
+ {onEdit && ( + + )}
diff --git a/frontend/pages/Dashboard.tsx b/frontend/pages/Dashboard.tsx index e0845ae..2262702 100644 --- a/frontend/pages/Dashboard.tsx +++ b/frontend/pages/Dashboard.tsx @@ -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 = ({ initialView = "list", }) => { const { user } = useAuth(); + const navigate = useNavigate(); const { events, getEventsByRole, @@ -396,8 +398,15 @@ export const Dashboard: React.FC = ({
- {/* Actions Toolbar */}
+ + {(user.role === UserRole.BUSINESS_OWNER || user.role === UserRole.SUPERADMIN) && ( <> @@ -434,6 +443,24 @@ export const Dashboard: React.FC = ({ > Abrir no Maps + +
@@ -878,8 +905,14 @@ export const Dashboard: React.FC = ({ ); 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 ( = ({ )} {!status && ( - - - Disponível + + {isBusy ? : } + {isBusy ? "Em outro evento" : "Disponível"} )} @@ -953,7 +986,7 @@ export const Dashboard: React.FC = ({ 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 = ({ ); 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 (
handleViewProfessional(photographer)}> @@ -1030,8 +1070,9 @@ export const Dashboard: React.FC = ({ )} {!status && ( - - Disponível + + {isBusy ? : } + {isBusy ? "Em outro evento" : "Disponível"} )}
@@ -1045,12 +1086,13 @@ export const Dashboard: React.FC = ({
diff --git a/frontend/pages/EventDetails.tsx b/frontend/pages/EventDetails.tsx new file mode 100644 index 0000000..2afce53 --- /dev/null +++ b/frontend/pages/EventDetails.tsx @@ -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(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
Carregando detalhes do evento...
; + if (!event) return
Evento não encontrado.
; + + const formattedDate = new Date(event.data_evento).toLocaleDateString(); + + return ( +
+
+ {/* Header */} +
+ +
+

+ {event.empresa_nome} - {event.tipo_evento_nome} + + {event.status} + +

+

ID: {event.fot_id}

+
+
+ + {/* Event Info Card (Spreadsheet Header Style) */} +
+
+ +
+

Data

+

{formattedDate}

+
+
+
+ +
+

Local:

+ {(() => { + const localVal = event['local_evento'] || event.local || event.local_evento; + const isUrl = localVal && String(localVal).startsWith('http'); + + if (isUrl) { + return ( + + {event.locationName || "Ver Localização no Mapa"} + + ); + } + return

{localVal || "Não informado"}

; + })()} +

{event.endereco}

+
+
+
+ +
+

Horário

+

{event.horario}

+
+
+
+
?
+
+

Observações

+

{event.observacoes_evento || "Nenhuma observação."}

+
+
+
+ + {/* Main Content: Scheduling & Logistics */} +
+ {/* Left: Scheduling (Escala) */} + p.professional_id)} + onUpdateStats={setCalculatedStats} + defaultTime={event.horario} + /> + + {/* Right: Logistics (Carros) */} +
+ p.professional_id)} + /> + + {/* Equipment / Studios Section (Placeholder for now based on spreadsheet) */} +
+

+ {/* Using DollarSign as generic icon for assets/inventory for now, map to Camera later */} + Equipamentos & Estúdios +

+
+
+ Qtd. Estúdios (Automático): + {calculatedStats.studios} +
+
+ Ponto de Foto: + {event.qtd_ponto_foto || 0} +
+
+

Notas de Equipamento:

+