Merge pull request #55 from rede5/logistica-diaria-nova-funcionalidade-11351179265

Feat: Logística Diária, Cargo Pesquisador, Melhorias Financeiro e Correções na Agenda
This commit is contained in:
Andre F. Rodrigues 2026-02-24 18:43:59 -03:00 committed by GitHub
commit 8c6bb6dfa3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 3103 additions and 121 deletions

View file

@ -270,6 +270,13 @@ func main() {
logisticaGroup.POST("/carros/:id/passageiros", logisticaHandler.AddPassenger)
logisticaGroup.DELETE("/carros/:id/passageiros/:profID", logisticaHandler.RemovePassenger)
logisticaGroup.GET("/carros/:id/passageiros", logisticaHandler.ListPassengers)
// Daily Invitations and Available Staff
logisticaGroup.GET("/disponiveis", logisticaHandler.ListProfissionaisDisponiveis)
logisticaGroup.POST("/convites", logisticaHandler.CreateConvite)
logisticaGroup.GET("/convites", logisticaHandler.ListConvitesProfissional)
logisticaGroup.PUT("/convites/:id", logisticaHandler.ResponderConvite)
logisticaGroup.GET("/convites-por-data", logisticaHandler.ListConvitesPorData)
}
codigosGroup := api.Group("/codigos-acesso")

View file

@ -146,6 +146,12 @@ func (h *Handler) Register(c *gin.Context) {
MaxAge: 30 * 24 * 60 * 60,
})
// Calculate MaxAge for access token cookie based on role
accessMaxAge := 180 * 60 // Default 3 hours
if req.Role == "RESEARCHER" {
accessMaxAge = 30 * 24 * 60 * 60 // 30 days
}
// Set access_token cookie for fallback
http.SetCookie(c.Writer, &http.Cookie{
Name: "access_token",
@ -154,7 +160,7 @@ func (h *Handler) Register(c *gin.Context) {
HttpOnly: true,
Secure: false,
SameSite: http.SameSiteStrictMode,
MaxAge: 180 * 60, // 3 hours
MaxAge: accessMaxAge,
})
c.JSON(http.StatusCreated, gin.H{
@ -235,6 +241,12 @@ func (h *Handler) Login(c *gin.Context) {
MaxAge: 30 * 24 * 60 * 60,
})
// Calculate MaxAge for access token cookie based on role
accessMaxAge := 180 * 60 // Default 3 hours
if user.Role == "RESEARCHER" {
accessMaxAge = 30 * 24 * 60 * 60 // 30 days
}
// Set access_token cookie for fallback
http.SetCookie(c.Writer, &http.Cookie{
Name: "access_token",
@ -243,7 +255,7 @@ func (h *Handler) Login(c *gin.Context) {
HttpOnly: true,
Secure: false,
SameSite: http.SameSiteStrictMode,
MaxAge: 180 * 60, // 3 hours
MaxAge: accessMaxAge,
})
// Handle Nullable Fields

View file

@ -168,6 +168,34 @@ func (s *Service) Login(ctx context.Context, email, senha string) (*TokenPair, *
return nil, nil, nil, err
}
// Persist the Refresh Token in the database
refreshHash := sha256.Sum256([]byte(refreshToken))
refreshHashString := hex.EncodeToString(refreshHash[:])
refreshExp := time.Now().Add(time.Duration(s.jwtRefreshTTLDays) * 24 * time.Hour)
// For Researchers, we can ensure the refresh token lives just as long as the extended access token + margin
if user.Role == "RESEARCHER" {
refreshExp = time.Now().Add(35 * 24 * time.Hour) // 35 days
}
var ip, userAgent string // We could pass them from context if needed, but for now leave blank
var expTime pgtype.Timestamptz
expTime.Time = refreshExp
expTime.Valid = true
_, err = s.queries.CreateRefreshToken(ctx, generated.CreateRefreshTokenParams{
UsuarioID: user.ID,
TokenHash: refreshHashString,
UserAgent: toPgText(&userAgent),
Ip: toPgText(&ip),
ExpiraEm: expTime,
})
if err != nil {
fmt.Printf("[DEBUG] Failed to save refresh token: %v\n", err)
// We still return true login, but refresh might fail later
}
var profData *generated.GetProfissionalByUsuarioIDRow
if user.Role == RolePhotographer || user.Role == RoleBusinessOwner {
p, err := s.queries.GetProfissionalByUsuarioID(ctx, user.ID)

View file

@ -15,6 +15,11 @@ type Claims struct {
}
func GenerateAccessToken(userID uuid.UUID, role string, regioes []string, secret string, ttlMinutes int) (string, time.Time, error) {
// Extend TTL to 30 days specifically for RESEARCHER role to prevent session drop
if role == "RESEARCHER" {
ttlMinutes = 30 * 24 * 60
}
expirationTime := time.Now().Add(time.Duration(ttlMinutes) * time.Minute)
claims := &Claims{
UserID: userID,

View file

@ -0,0 +1,188 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: convites_diarios.sql
package generated
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const createConviteDiario = `-- name: CreateConviteDiario :one
INSERT INTO convites_diarios (
profissional_id, data, status, regiao
) VALUES (
$1, $2, $3, $4
)
RETURNING id, profissional_id, data, status, motivo_rejeicao, regiao, criado_em, atualizado_em
`
type CreateConviteDiarioParams struct {
ProfissionalID pgtype.UUID `json:"profissional_id"`
Data pgtype.Date `json:"data"`
Status pgtype.Text `json:"status"`
Regiao pgtype.Text `json:"regiao"`
}
func (q *Queries) CreateConviteDiario(ctx context.Context, arg CreateConviteDiarioParams) (ConvitesDiario, error) {
row := q.db.QueryRow(ctx, createConviteDiario,
arg.ProfissionalID,
arg.Data,
arg.Status,
arg.Regiao,
)
var i ConvitesDiario
err := row.Scan(
&i.ID,
&i.ProfissionalID,
&i.Data,
&i.Status,
&i.MotivoRejeicao,
&i.Regiao,
&i.CriadoEm,
&i.AtualizadoEm,
)
return i, err
}
const deleteConvite = `-- name: DeleteConvite :exec
DELETE FROM convites_diarios
WHERE id = $1
`
func (q *Queries) DeleteConvite(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteConvite, id)
return err
}
const getConviteByProfissionalAndDate = `-- name: GetConviteByProfissionalAndDate :one
SELECT id, profissional_id, data, status, motivo_rejeicao, regiao, criado_em, atualizado_em FROM convites_diarios
WHERE profissional_id = $1 AND data = $2
`
type GetConviteByProfissionalAndDateParams struct {
ProfissionalID pgtype.UUID `json:"profissional_id"`
Data pgtype.Date `json:"data"`
}
func (q *Queries) GetConviteByProfissionalAndDate(ctx context.Context, arg GetConviteByProfissionalAndDateParams) (ConvitesDiario, error) {
row := q.db.QueryRow(ctx, getConviteByProfissionalAndDate, arg.ProfissionalID, arg.Data)
var i ConvitesDiario
err := row.Scan(
&i.ID,
&i.ProfissionalID,
&i.Data,
&i.Status,
&i.MotivoRejeicao,
&i.Regiao,
&i.CriadoEm,
&i.AtualizadoEm,
)
return i, err
}
const getConvitesByDateAndRegion = `-- name: GetConvitesByDateAndRegion :many
SELECT id, profissional_id, data, status, motivo_rejeicao, regiao, criado_em, atualizado_em FROM convites_diarios
WHERE data = $1 AND regiao = $2
`
type GetConvitesByDateAndRegionParams struct {
Data pgtype.Date `json:"data"`
Regiao pgtype.Text `json:"regiao"`
}
func (q *Queries) GetConvitesByDateAndRegion(ctx context.Context, arg GetConvitesByDateAndRegionParams) ([]ConvitesDiario, error) {
rows, err := q.db.Query(ctx, getConvitesByDateAndRegion, arg.Data, arg.Regiao)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ConvitesDiario
for rows.Next() {
var i ConvitesDiario
if err := rows.Scan(
&i.ID,
&i.ProfissionalID,
&i.Data,
&i.Status,
&i.MotivoRejeicao,
&i.Regiao,
&i.CriadoEm,
&i.AtualizadoEm,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getConvitesByProfissional = `-- name: GetConvitesByProfissional :many
SELECT id, profissional_id, data, status, motivo_rejeicao, regiao, criado_em, atualizado_em FROM convites_diarios
WHERE profissional_id = $1
ORDER BY data ASC
`
func (q *Queries) GetConvitesByProfissional(ctx context.Context, profissionalID pgtype.UUID) ([]ConvitesDiario, error) {
rows, err := q.db.Query(ctx, getConvitesByProfissional, profissionalID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ConvitesDiario
for rows.Next() {
var i ConvitesDiario
if err := rows.Scan(
&i.ID,
&i.ProfissionalID,
&i.Data,
&i.Status,
&i.MotivoRejeicao,
&i.Regiao,
&i.CriadoEm,
&i.AtualizadoEm,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateConviteStatus = `-- name: UpdateConviteStatus :one
UPDATE convites_diarios
SET status = $2, motivo_rejeicao = $3, atualizado_em = NOW()
WHERE id = $1
RETURNING id, profissional_id, data, status, motivo_rejeicao, regiao, criado_em, atualizado_em
`
type UpdateConviteStatusParams struct {
ID pgtype.UUID `json:"id"`
Status pgtype.Text `json:"status"`
MotivoRejeicao pgtype.Text `json:"motivo_rejeicao"`
}
func (q *Queries) UpdateConviteStatus(ctx context.Context, arg UpdateConviteStatusParams) (ConvitesDiario, error) {
row := q.db.QueryRow(ctx, updateConviteStatus, arg.ID, arg.Status, arg.MotivoRejeicao)
var i ConvitesDiario
err := row.Scan(
&i.ID,
&i.ProfissionalID,
&i.Data,
&i.Status,
&i.MotivoRejeicao,
&i.Regiao,
&i.CriadoEm,
&i.AtualizadoEm,
)
return i, err
}

View file

@ -0,0 +1,164 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: logistica_disponiveis.sql
package generated
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const countProfissionaisDisponiveisLogistica = `-- name: CountProfissionaisDisponiveisLogistica :one
SELECT COUNT(p.id)
FROM cadastro_profissionais p
WHERE p.regiao = $1
AND ($2::text = '' OR p.nome ILIKE '%' || $2 || '%')
AND NOT EXISTS (
SELECT 1 FROM convites_diarios cd
WHERE cd.profissional_id = p.id
AND cd.data = CAST($3 AS DATE)
AND cd.status IN ('PENDENTE', 'ACEITO')
)
`
type CountProfissionaisDisponiveisLogisticaParams struct {
Regiao pgtype.Text `json:"regiao"`
Column2 string `json:"column_2"`
Column3 pgtype.Date `json:"column_3"`
}
func (q *Queries) CountProfissionaisDisponiveisLogistica(ctx context.Context, arg CountProfissionaisDisponiveisLogisticaParams) (int64, error) {
row := q.db.QueryRow(ctx, countProfissionaisDisponiveisLogistica, arg.Regiao, arg.Column2, arg.Column3)
var count int64
err := row.Scan(&count)
return count, err
}
const getProfissionaisDisponiveisLogistica = `-- name: GetProfissionaisDisponiveisLogistica :many
SELECT p.id, p.usuario_id, p.nome, p.funcao_profissional_id, p.endereco, p.cidade, p.uf, p.whatsapp, p.cep, p.numero, p.complemento, p.bairro, p.cpf_cnpj_titular, p.banco, p.agencia, p.conta, p.conta_pix, p.carro_disponivel, p.tem_estudio, p.qtd_estudio, p.tipo_cartao, p.observacao, p.qual_tec, p.educacao_simpatia, p.desempenho_evento, p.disp_horario, p.media, p.tabela_free, p.extra_por_equipamento, p.equipamentos, p.email, p.avatar_url, p.criado_em, p.atualizado_em, p.regiao, f.nome as funcao_nome
FROM cadastro_profissionais p
LEFT JOIN funcoes_profissionais f ON p.funcao_profissional_id = f.id
WHERE p.regiao = $1
AND ($2::text = '' OR p.nome ILIKE '%' || $2 || '%')
AND NOT EXISTS (
SELECT 1 FROM convites_diarios cd
WHERE cd.profissional_id = p.id
AND cd.data = CAST($3 AS DATE)
AND cd.status IN ('PENDENTE', 'ACEITO')
)
ORDER BY p.nome ASC
LIMIT $4 OFFSET $5
`
type GetProfissionaisDisponiveisLogisticaParams struct {
Regiao pgtype.Text `json:"regiao"`
Column2 string `json:"column_2"`
Column3 pgtype.Date `json:"column_3"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
type GetProfissionaisDisponiveisLogisticaRow struct {
ID pgtype.UUID `json:"id"`
UsuarioID pgtype.UUID `json:"usuario_id"`
Nome string `json:"nome"`
FuncaoProfissionalID pgtype.UUID `json:"funcao_profissional_id"`
Endereco pgtype.Text `json:"endereco"`
Cidade pgtype.Text `json:"cidade"`
Uf pgtype.Text `json:"uf"`
Whatsapp pgtype.Text `json:"whatsapp"`
Cep pgtype.Text `json:"cep"`
Numero pgtype.Text `json:"numero"`
Complemento pgtype.Text `json:"complemento"`
Bairro pgtype.Text `json:"bairro"`
CpfCnpjTitular pgtype.Text `json:"cpf_cnpj_titular"`
Banco pgtype.Text `json:"banco"`
Agencia pgtype.Text `json:"agencia"`
Conta pgtype.Text `json:"conta"`
ContaPix pgtype.Text `json:"conta_pix"`
CarroDisponivel pgtype.Bool `json:"carro_disponivel"`
TemEstudio pgtype.Bool `json:"tem_estudio"`
QtdEstudio pgtype.Int4 `json:"qtd_estudio"`
TipoCartao pgtype.Text `json:"tipo_cartao"`
Observacao pgtype.Text `json:"observacao"`
QualTec pgtype.Int4 `json:"qual_tec"`
EducacaoSimpatia pgtype.Int4 `json:"educacao_simpatia"`
DesempenhoEvento pgtype.Int4 `json:"desempenho_evento"`
DispHorario pgtype.Int4 `json:"disp_horario"`
Media pgtype.Numeric `json:"media"`
TabelaFree pgtype.Text `json:"tabela_free"`
ExtraPorEquipamento pgtype.Bool `json:"extra_por_equipamento"`
Equipamentos pgtype.Text `json:"equipamentos"`
Email pgtype.Text `json:"email"`
AvatarUrl pgtype.Text `json:"avatar_url"`
CriadoEm pgtype.Timestamptz `json:"criado_em"`
AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"`
Regiao pgtype.Text `json:"regiao"`
FuncaoNome pgtype.Text `json:"funcao_nome"`
}
func (q *Queries) GetProfissionaisDisponiveisLogistica(ctx context.Context, arg GetProfissionaisDisponiveisLogisticaParams) ([]GetProfissionaisDisponiveisLogisticaRow, error) {
rows, err := q.db.Query(ctx, getProfissionaisDisponiveisLogistica,
arg.Regiao,
arg.Column2,
arg.Column3,
arg.Limit,
arg.Offset,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetProfissionaisDisponiveisLogisticaRow
for rows.Next() {
var i GetProfissionaisDisponiveisLogisticaRow
if err := rows.Scan(
&i.ID,
&i.UsuarioID,
&i.Nome,
&i.FuncaoProfissionalID,
&i.Endereco,
&i.Cidade,
&i.Uf,
&i.Whatsapp,
&i.Cep,
&i.Numero,
&i.Complemento,
&i.Bairro,
&i.CpfCnpjTitular,
&i.Banco,
&i.Agencia,
&i.Conta,
&i.ContaPix,
&i.CarroDisponivel,
&i.TemEstudio,
&i.QtdEstudio,
&i.TipoCartao,
&i.Observacao,
&i.QualTec,
&i.EducacaoSimpatia,
&i.DesempenhoEvento,
&i.DispHorario,
&i.Media,
&i.TabelaFree,
&i.ExtraPorEquipamento,
&i.Equipamentos,
&i.Email,
&i.AvatarUrl,
&i.CriadoEm,
&i.AtualizadoEm,
&i.Regiao,
&i.FuncaoNome,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

View file

@ -159,6 +159,17 @@ type CodigosAcesso struct {
Regiao pgtype.Text `json:"regiao"`
}
type ConvitesDiario struct {
ID pgtype.UUID `json:"id"`
ProfissionalID pgtype.UUID `json:"profissional_id"`
Data pgtype.Date `json:"data"`
Status pgtype.Text `json:"status"`
MotivoRejeicao pgtype.Text `json:"motivo_rejeicao"`
Regiao pgtype.Text `json:"regiao"`
CriadoEm pgtype.Timestamptz `json:"criado_em"`
AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"`
}
type Curso struct {
ID pgtype.UUID `json:"id"`
Nome string `json:"nome"`

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS convites_diarios CASCADE;

View file

@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS convites_diarios (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
profissional_id UUID NOT NULL REFERENCES cadastro_profissionais(id) ON DELETE CASCADE,
data DATE NOT NULL,
status VARCHAR(20) DEFAULT 'PENDENTE', -- PENDENTE, ACEITO, REJEITADO
motivo_rejeicao TEXT,
regiao CHAR(2) DEFAULT 'SP',
criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
atualizado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(profissional_id, data)
);

View file

@ -0,0 +1,30 @@
-- name: CreateConviteDiario :one
INSERT INTO convites_diarios (
profissional_id, data, status, regiao
) VALUES (
$1, $2, $3, $4
)
RETURNING *;
-- name: GetConvitesByProfissional :many
SELECT * FROM convites_diarios
WHERE profissional_id = $1
ORDER BY data ASC;
-- name: GetConvitesByDateAndRegion :many
SELECT * FROM convites_diarios
WHERE data = $1 AND regiao = $2;
-- name: GetConviteByProfissionalAndDate :one
SELECT * FROM convites_diarios
WHERE profissional_id = $1 AND data = $2;
-- name: UpdateConviteStatus :one
UPDATE convites_diarios
SET status = $2, motivo_rejeicao = $3, atualizado_em = NOW()
WHERE id = $1
RETURNING *;
-- name: DeleteConvite :exec
DELETE FROM convites_diarios
WHERE id = $1;

View file

@ -0,0 +1,26 @@
-- name: GetProfissionaisDisponiveisLogistica :many
SELECT p.*, f.nome as funcao_nome
FROM cadastro_profissionais p
LEFT JOIN funcoes_profissionais f ON p.funcao_profissional_id = f.id
WHERE p.regiao = $1
AND ($2::text = '' OR p.nome ILIKE '%' || $2 || '%')
AND NOT EXISTS (
SELECT 1 FROM convites_diarios cd
WHERE cd.profissional_id = p.id
AND cd.data = CAST($3 AS DATE)
AND cd.status IN ('PENDENTE', 'ACEITO')
)
ORDER BY p.nome ASC
LIMIT $4 OFFSET $5;
-- name: CountProfissionaisDisponiveisLogistica :one
SELECT COUNT(p.id)
FROM cadastro_profissionais p
WHERE p.regiao = $1
AND ($2::text = '' OR p.nome ILIKE '%' || $2 || '%')
AND NOT EXISTS (
SELECT 1 FROM convites_diarios cd
WHERE cd.profissional_id = p.id
AND cd.data = CAST($3 AS DATE)
AND cd.status IN ('PENDENTE', 'ACEITO')
);

View file

@ -262,6 +262,18 @@ CREATE TABLE IF NOT EXISTS agenda_profissionais (
UNIQUE(agenda_id, profissional_id)
);
CREATE TABLE IF NOT EXISTS convites_diarios (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
profissional_id UUID NOT NULL REFERENCES cadastro_profissionais(id) ON DELETE CASCADE,
data DATE NOT NULL,
status VARCHAR(20) DEFAULT 'PENDENTE', -- PENDENTE, ACEITO, REJEITADO
motivo_rejeicao TEXT,
regiao CHAR(2) DEFAULT 'SP',
criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
atualizado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(profissional_id, data)
);
CREATE TABLE IF NOT EXISTS disponibilidade_profissionais (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
usuario_id UUID NOT NULL REFERENCES usuarios(id) ON DELETE CASCADE,

View file

@ -162,3 +162,155 @@ func (h *Handler) ListPassengers(c *gin.Context) {
}
c.JSON(http.StatusOK, resp)
}
// ---- LOGISTICA DAILY INVITATIONS & PROFESSIONALS LIST ---- //
func (h *Handler) ListProfissionaisDisponiveis(c *gin.Context) {
var query ListDisponiveisInput
if err := c.ShouldBindQuery(&query); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
regiao := c.GetString("regiao")
res, err := h.service.ListProfissionaisDisponiveisLogistica(c.Request.Context(), query, regiao)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
formatted := make([]map[string]interface{}, len(res.Data))
for i, r := range res.Data {
formatted[i] = map[string]interface{}{
"id": uuid.UUID(r.ID.Bytes).String(),
"nome": r.Nome,
"role": r.FuncaoNome.String,
"avatar_url": r.AvatarUrl.String,
"whatsapp": r.Whatsapp.String,
"cidade": r.Cidade.String,
"carro_disponivel": r.CarroDisponivel.Bool,
}
}
c.JSON(http.StatusOK, gin.H{
"data": formatted,
"total": res.Total,
"page": res.Page,
"total_pages": res.TotalPages,
})
}
func (h *Handler) CreateConvite(c *gin.Context) {
var req CreateConviteInput
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
regiao := c.GetString("regiao")
convite, err := h.service.CreateConviteDiario(c.Request.Context(), req, regiao)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"id": uuid.UUID(convite.ID.Bytes).String()})
}
// Get Invitations for logged professional
func (h *Handler) ListConvitesProfissional(c *gin.Context) {
// For MVP: Let the frontend send the prof ID or use a dedicated endpoint where frontend provides it.
// Actually frontend can provide ?prof_id=...
pID := c.Query("prof_id")
if pID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "prof_id is required"})
return
}
convites, err := h.service.ListConvitesDoProfissional(c.Request.Context(), pID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Format response nicely
resp := make([]map[string]interface{}, len(convites))
for i, c := range convites {
// convert time.Time to YYYY-MM-DD
var dateStr string
if c.Data.Valid {
dateStr = c.Data.Time.Format("2006-01-02")
}
resp[i] = map[string]interface{}{
"id": uuid.UUID(c.ID.Bytes).String(),
"profissional_id": uuid.UUID(c.ProfissionalID.Bytes).String(),
"data": dateStr,
"status": c.Status.String,
"motivo_rejeicao": c.MotivoRejeicao.String,
"criado_em": c.CriadoEm.Time.Format("2006-01-02T15:04:05Z07:00"),
}
}
c.JSON(http.StatusOK, resp)
}
// Respond to an invitation
func (h *Handler) ResponderConvite(c *gin.Context) {
conviteID := c.Param("id")
if conviteID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "id required"})
return
}
var req ResponderConviteInput
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
convite, err := h.service.ResponderConvite(c.Request.Context(), conviteID, req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": convite.Status.String})
}
// Get Invitations by Date for Logistics Manager
func (h *Handler) ListConvitesPorData(c *gin.Context) {
dataStr := c.Query("data")
if dataStr == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "data is required"})
return
}
regiao := c.GetString("regiao")
convites, err := h.service.ListConvitesPorData(c.Request.Context(), dataStr, regiao)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Format response
resp := make([]map[string]interface{}, len(convites))
for i, conv := range convites {
var dateStr string
if conv.Data.Valid {
dateStr = conv.Data.Time.Format("2006-01-02")
}
resp[i] = map[string]interface{}{
"id": uuid.UUID(conv.ID.Bytes).String(),
"profissional_id": uuid.UUID(conv.ProfissionalID.Bytes).String(),
"data": dateStr,
"status": conv.Status.String,
"motivo_rejeicao": conv.MotivoRejeicao.String,
"criado_em": conv.CriadoEm.Time.Format("2006-01-02T15:04:05Z07:00"),
}
}
c.JSON(http.StatusOK, resp)
}

View file

@ -2,6 +2,8 @@ package logistica
import (
"context"
"fmt"
"time"
"photum-backend/internal/config"
"photum-backend/internal/db/generated"
@ -248,3 +250,157 @@ func (s *Service) ListPassageiros(ctx context.Context, carroID string) ([]genera
}
return s.queries.ListPassageirosByCarroID(ctx, pgtype.UUID{Bytes: cID, Valid: true})
}
// ---- LOGISTICA DAILY INVITATIONS & PROFESSIONALS LIST ---- //
type ListDisponiveisInput struct {
Data string `form:"data" binding:"required"`
Search string `form:"search"`
Page int `form:"page,default=1"`
Limit int `form:"limit,default=50"`
}
type ListDisponiveisResult struct {
Data []generated.GetProfissionaisDisponiveisLogisticaRow `json:"data"`
Total int64 `json:"total"`
Page int `json:"page"`
TotalPages int `json:"total_pages"`
}
func (s *Service) ListProfissionaisDisponiveisLogistica(ctx context.Context, input ListDisponiveisInput, regiao string) (*ListDisponiveisResult, error) {
offset := (input.Page - 1) * input.Limit
parsedDate, parsedDateErr := time.Parse("2006-01-02", input.Data)
arg := generated.GetProfissionaisDisponiveisLogisticaParams{
Regiao: pgtype.Text{String: regiao, Valid: true},
Column2: input.Search,
Column3: pgtype.Date{Time: parsedDate, Valid: parsedDateErr == nil},
Limit: int32(input.Limit),
Offset: int32(offset),
}
rows, err := s.queries.GetProfissionaisDisponiveisLogistica(ctx, arg)
if err != nil {
return nil, err
}
countArg := generated.CountProfissionaisDisponiveisLogisticaParams{
Regiao: pgtype.Text{String: regiao, Valid: true},
Column2: input.Search,
Column3: pgtype.Date{Time: parsedDate, Valid: parsedDateErr == nil},
}
total, err := s.queries.CountProfissionaisDisponiveisLogistica(ctx, countArg)
if err != nil {
return nil, err
}
if rows == nil {
rows = []generated.GetProfissionaisDisponiveisLogisticaRow{}
}
totalPages := int(total) / input.Limit
if int(total)%input.Limit > 0 {
totalPages++
}
return &ListDisponiveisResult{
Data: rows,
Total: total,
Page: input.Page,
TotalPages: totalPages,
}, nil
}
type CreateConviteInput struct {
ProfissionalID string `json:"profissional_id" binding:"required"`
Data string `json:"data" binding:"required"`
}
func (s *Service) CreateConviteDiario(ctx context.Context, input CreateConviteInput, regiao string) (*generated.ConvitesDiario, error) {
profUUID, err := uuid.Parse(input.ProfissionalID)
if err != nil {
return nil, err
}
arg := generated.CreateConviteDiarioParams{
ProfissionalID: pgtype.UUID{Bytes: profUUID, Valid: true},
Status: pgtype.Text{String: "PENDENTE", Valid: true},
Regiao: pgtype.Text{String: regiao, Valid: true},
}
t, err := time.Parse("2006-01-02", input.Data)
if err == nil {
arg.Data = pgtype.Date{Time: t, Valid: true}
}
convite, err := s.queries.CreateConviteDiario(ctx, arg)
if err != nil {
return nil, err
}
return &convite, nil
}
// List Invitations for a Professional
func (s *Service) ListConvitesDoProfissional(ctx context.Context, profID string) ([]generated.ConvitesDiario, error) {
pID, err := uuid.Parse(profID)
if err != nil {
return nil, err
}
return s.queries.GetConvitesByProfissional(ctx, pgtype.UUID{Bytes: pID, Valid: true})
}
// Answer Invitation
type ResponderConviteInput struct {
Status string `json:"status" binding:"required"` // ACEITO, REJEITADO
MotivoRejeicao string `json:"motivo_rejeicao"`
}
func (s *Service) ResponderConvite(ctx context.Context, conviteID string, input ResponderConviteInput) (*generated.ConvitesDiario, error) {
cID, err := uuid.Parse(conviteID)
if err != nil {
return nil, err
}
validStatus := input.Status == "ACEITO" || input.Status == "REJEITADO"
if !validStatus {
return nil, fmt.Errorf("status inválido") // fmt must be imported, Oh wait.. just use errors.New if fmt is not there. I will just rely on simple return. Wait I can use built-in error.
}
arg := generated.UpdateConviteStatusParams{
ID: pgtype.UUID{Bytes: cID, Valid: true},
Status: pgtype.Text{String: input.Status, Valid: true},
MotivoRejeicao: pgtype.Text{String: input.MotivoRejeicao, Valid: input.MotivoRejeicao != ""},
}
convite, err := s.queries.UpdateConviteStatus(ctx, arg)
if err != nil {
return nil, err
}
return &convite, nil
}
// List Invitations by Date and Region (Logistics view)
func (s *Service) ListConvitesPorData(ctx context.Context, dataStr string, regiao string) ([]generated.ConvitesDiario, error) {
parsedDate, err := time.Parse("2006-01-02", dataStr)
if err != nil {
return nil, fmt.Errorf("formato de data inválido, esperado YYYY-MM-DD")
}
datePg := pgtype.Date{
Time: parsedDate,
Valid: true,
}
regPg := pgtype.Text{
String: regiao,
Valid: regiao != "",
}
return s.queries.GetConvitesByDateAndRegion(ctx, generated.GetConvitesByDateAndRegionParams{
Data: datePg,
Regiao: regPg,
})
}

View file

@ -17,6 +17,7 @@ import { RegistrationSuccess } from "./pages/RegistrationSuccess";
import { TeamPage } from "./pages/Team";
import EventDetails from "./pages/EventDetails";
import Finance from "./pages/Finance";
import { DailyLogistics } from "./pages/DailyLogistics";
import { SettingsPage } from "./pages/Settings";
import { CourseManagement } from "./pages/CourseManagement";
@ -641,6 +642,16 @@ const AppContent: React.FC = () => {
</ProtectedRoute>
}
/>
<Route
path="/logistica-diaria"
element={
<ProtectedRoute>
<PageWrapper>
<DailyLogistics />
</PageWrapper>
</ProtectedRoute>
}
/>
<Route
path="/agenda/:id"
element={

View file

@ -74,6 +74,7 @@ export const Navbar: React.FC<NavbarProps> = ({ onNavigate, currentPage }) => {
return [
{ name: "Agenda", path: "painel" },
{ name: "Equipe", path: "equipe" },
{ name: "Logística", path: "logistica-diaria" },
{ name: "Cadastro de FOT", path: "cursos" },
{ name: "Aprovação de Cadastros", path: "aprovacao-cadastros" },
{ name: "Códigos de Acesso", path: "codigos-acesso" },

File diff suppressed because it is too large Load diff

View file

@ -11,6 +11,8 @@
"dependencies": {
"@google/genai": "^1.30.0",
"@types/mapbox-gl": "^3.4.1",
"exceljs": "^4.4.0",
"file-saver": "^2.0.5",
"lucide-react": "^0.554.0",
"mapbox-gl": "^3.16.0",
"react": "^19.2.0",

View file

@ -0,0 +1,574 @@
import React, { useState, useEffect, useMemo } from 'react';
import { useData } from '../contexts/DataContext';
import { EventData, EventStatus, Professional } from '../types';
import { RefreshCw, Users, CheckCircle, AlertTriangle, Calendar as CalendarIcon, MapPin, Clock, UserPlus, ChevronLeft, ChevronRight, ChevronDown, ChevronUp } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { getAvailableProfessionalsLogistics, createDailyInvitation, getDailyInvitationsByDate } from '../services/apiService';
import { toast } from 'react-hot-toast';
import { ProfessionalDetailsModal } from '../components/ProfessionalDetailsModal';
import { Car } from 'lucide-react'; // Added Car icon
export const DailyLogistics: React.FC = () => {
const { events, professionals, isLoading, refreshEvents } = useData();
const [selectedDate, setSelectedDate] = useState<string>(new Date().toISOString().split('T')[0]);
const navigate = useNavigate();
const [searchTerm, setSearchTerm] = useState("");
const [dailyInvitations, setDailyInvitations] = useState<any[]>([]);
const [isInvitationsExpanded, setIsInvitationsExpanded] = useState(true);
const [viewingProfessional, setViewingProfessional] = useState<Professional | null>(null);
const [isProfModalOpen, setIsProfModalOpen] = useState(false);
// Utility para cores de cidade baseado na planilha do cliente + cores dinâmicas
const getCityColor = (city: string | undefined) => {
if (!city) return 'bg-gray-100 text-gray-700 border-gray-200'; // Default
const c = city.toLowerCase().trim();
if (c.includes('campinas')) return 'bg-pink-100 text-pink-700 border-pink-200';
if (c.includes('piracicaba')) return 'bg-cyan-100 text-cyan-700 border-cyan-200';
if (c.includes('paulo') || c.includes('sp')) return 'bg-slate-200 text-slate-700 border-slate-300';
if (c.includes('americana') || c.includes('sbo') || c.includes('barbara') || c.includes('bárbara') || c.includes('santa barbara')) return 'bg-green-100 text-green-700 border-green-200';
if (c.includes('indaiatuba')) return 'bg-yellow-100 text-yellow-700 border-yellow-200';
// Dynamic color for others
const palette = [
'bg-purple-100 text-purple-700 border-purple-200',
'bg-indigo-100 text-indigo-700 border-indigo-200',
'bg-teal-100 text-teal-700 border-teal-200',
'bg-orange-100 text-orange-700 border-orange-200',
'bg-rose-100 text-rose-700 border-rose-200',
'bg-violet-100 text-violet-700 border-violet-200',
'bg-emerald-100 text-emerald-700 border-emerald-200',
'bg-fuchsia-100 text-fuchsia-700 border-fuchsia-200'
];
let hash = 0;
for (let i = 0; i < c.length; i++) {
hash = c.charCodeAt(i) + ((hash << 5) - hash);
}
return palette[Math.abs(hash) % palette.length];
};
const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSelectedDate(e.target.value);
};
const handleRefresh = () => {
refreshEvents();
};
// Filter events by the selected date
const eventsInDay = useMemo(() => {
return events.filter(e => {
if (!e.date) return false;
// e.date might be YYYY-MM-DD or full ISO
const eventDateStr = e.date.split('T')[0];
return eventDateStr === selectedDate;
});
}, [events, selectedDate]);
// Aggregate metrics
const metrics = useMemo(() => {
let totalNeeded = 0;
let totalAccepted = 0;
let totalPending = 0;
// Detailed Role missing counts
let fotMissing = 0;
let recMissing = 0;
let cinMissing = 0;
// Track professionals already counted in events for this day
const eventAcceptedProfIds = new Set<string>();
const eventPendingProfIds = new Set<string>();
eventsInDay.forEach(event => {
// Required professionals
const fotReq = Number(event.qtdFotografos || 0);
const recReq = Number(event.qtdRecepcionistas || 0);
const cinReq = Number(event.qtdCinegrafistas || 0);
const eventTotalNeeded = fotReq + recReq + cinReq;
totalNeeded += eventTotalNeeded;
let fotAc = 0, recAc = 0, cinAc = 0;
let eTotalAc = 0, eTotalPd = 0;
// Count assignments
if (event.assignments) {
event.assignments.forEach(assignment => {
const status = assignment.status ? String(assignment.status).toUpperCase() : "AGUARDANDO";
const prof = professionals.find(p => p.id === assignment.professionalId);
const roleName = prof?.role?.toLowerCase() || "";
if (status === "ACEITO" || status === "CONFIRMADO") {
eTotalAc++;
eventAcceptedProfIds.add(assignment.professionalId);
if (roleName.includes('fot')) fotAc++;
else if (roleName.includes('rece')) recAc++;
else if (roleName.includes('cin')) cinAc++;
else if (assignment.funcaoId === 'fotografo_id') fotAc++;
} else if (status === "AGUARDANDO" || status === "PENDENTE") {
eTotalPd++;
eventPendingProfIds.add(assignment.professionalId);
}
});
}
totalAccepted += eTotalAc;
totalPending += eTotalPd;
// Calculate missing per role for this event
fotMissing += Math.max(0, fotReq - fotAc);
recMissing += Math.max(0, recReq - recAc);
cinMissing += Math.max(0, cinReq - cinAc);
});
// Add Daily Invitations to the generic counters only if not already allocated in an event
const uniqueDailyPending = dailyInvitations.filter(i => i.status === 'PENDENTE' && !eventPendingProfIds.has(i.profissional_id) && !eventAcceptedProfIds.has(i.profissional_id)).length;
const uniqueDailyAccepted = dailyInvitations.filter(i => i.status === 'ACEITO' && !eventAcceptedProfIds.has(i.profissional_id)).length;
totalPending += uniqueDailyPending;
totalAccepted += uniqueDailyAccepted;
const totalMissing = Math.max(0, totalNeeded - totalAccepted);
return {
totalNeeded, totalAccepted, totalPending, totalMissing,
fotMissing, recMissing, cinMissing
};
}, [eventsInDay, professionals, dailyInvitations]);
const [paginatedProfessionals, setPaginatedProfessionals] = useState<any[]>([]);
const [totalPros, setTotalPros] = useState(0);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [isProsLoading, setIsProsLoading] = useState(false);
// Helper: Fetch available professionals from API
const fetchPros = async () => {
setIsProsLoading(true);
try {
const resp = await getAvailableProfessionalsLogistics(selectedDate, page, 50, searchTerm);
setPaginatedProfessionals(resp.data || []);
setTotalPros(resp.total || 0);
setTotalPages(resp.total_pages || 1);
} catch (err) {
console.error("Failed to fetch professionals", err);
toast.error("Erro ao carregar profissionais");
} finally {
setIsProsLoading(false);
}
};
const fetchDailyInvitations = async () => {
try {
const res = await getDailyInvitationsByDate(selectedDate);
setDailyInvitations(res || []);
} catch(err) {
console.error(err);
}
};
useEffect(() => {
fetchPros();
fetchDailyInvitations();
}, [selectedDate, page, searchTerm]);
// Select Event Context Handlers -> Now it's Daily Allocation!
const allocDaily = async (profId: string) => {
try {
await createDailyInvitation(profId, selectedDate);
toast.success("Convite de diária enviado!");
fetchPros(); // Refresh list to remove from available
fetchDailyInvitations(); // refresh the pending invitations section
} catch (err: any) {
toast.error(err.message || "Erro ao alocar diária");
}
};
// Render individual event card
const renderEventCard = (event: EventData) => {
const fotReq = Number(event.qtdFotografos || 0);
const recReq = Number(event.qtdRecepcionistas || 0);
const cinReq = Number(event.qtdCinegrafistas || 0);
const needed = fotReq + recReq + cinReq;
// Group accepted/pending counts by role directly from event's assignments
// We assume assignments map back to the professional's functions or are inherently mapped
let accepted = 0;
let pending = 0;
let fotAc = 0, recAc = 0, cinAc = 0;
event.assignments?.forEach(a => {
const status = a.status ? String(a.status).toUpperCase() : "AGUARDANDO";
const prof = professionals.find(p => p.id === a.professionalId);
const roleName = prof?.role?.toLowerCase() || "";
if (status === "ACEITO" || status === "CONFIRMADO") {
accepted++;
if (roleName.includes('fot')) fotAc++;
else if (roleName.includes('rece')) recAc++;
else if (roleName.includes('cin')) cinAc++;
else if (a.funcaoId === 'fotografo_id') fotAc++; // Fallback maps
}
else if (status === "AGUARDANDO" || status === "PENDENTE") pending++;
});
const missing = Math.max(0, needed - accepted);
return (
<div
key={event.id}
onClick={() => navigate(`/painel?eventId=${event.id}`)}
className="bg-white rounded-xl shadow-sm border border-gray-100 p-5 hover:shadow-md hover:border-brand-purple/50 transition-all cursor-pointer group"
>
<div className="flex justify-between items-start mb-4">
<div className="flex-1">
<h3 className="font-bold text-gray-900 group-hover:text-brand-purple transition-colors line-clamp-1 flex items-center gap-2">
{event.name}
<ChevronRight size={16} className="text-transparent group-hover:text-brand-purple transition-colors" />
</h3>
<div className="flex items-center text-xs text-gray-500 mt-2 gap-4">
<span className="flex items-center gap-1"><Clock size={14}/> {event.time}</span>
<span className="flex items-center gap-1 truncate max-w-[150px]"><MapPin size={14}/> {event.address?.city || "Cidade N/A"}</span>
</div>
</div>
<span className={`px-2 py-1 rounded-md text-xs font-semibold shrink-0 ml-2 ${missing > 0 ? 'bg-red-50 text-red-600' : 'bg-green-50 text-green-600'}`}>
{missing > 0 ? `Faltam ${missing}` : 'Completo'}
</span>
</div>
{/* Roles Breakdown */}
<div className="bg-gray-50 rounded-lg p-3 text-xs flex flex-col gap-2 border border-gray-100 mb-4">
<div className="flex justify-between items-center text-gray-600 font-medium pb-1 border-b border-gray-200">
<span>Função</span>
<div className="flex gap-4">
<span className="w-12 text-center" title="Aceitos / Necessários">A / N</span>
</div>
</div>
{fotReq > 0 && (
<div className="flex justify-between items-center">
<span className="text-gray-700">Fotógrafo</span>
<div className="flex gap-4 text-center">
<span className={`w-12 font-semibold ${fotAc < fotReq ? 'text-yellow-600' : 'text-green-600'}`}>
{fotAc} / {fotReq}
</span>
</div>
</div>
)}
{recReq > 0 && (
<div className="flex justify-between items-center">
<span className="text-gray-700">Recepcionista</span>
<div className="flex gap-4 text-center">
<span className={`w-12 font-semibold ${recAc < recReq ? 'text-yellow-600' : 'text-green-600'}`}>
{recAc} / {recReq}
</span>
</div>
</div>
)}
{cinReq > 0 && (
<div className="flex justify-between items-center">
<span className="text-gray-700">Cinegrafista</span>
<div className="flex gap-4 text-center">
<span className={`w-12 font-semibold ${cinAc < cinReq ? 'text-yellow-600' : 'text-green-600'}`}>
{cinAc} / {cinReq}
</span>
</div>
</div>
)}
{needed === 0 && (
<div className="text-gray-400 italic text-center py-1">Nenhuma vaga registrada</div>
)}
</div>
{Array.isArray(event.assignments) && event.assignments.length > 0 && (
<div className="mt-2 text-xs">
<div className="flex justify-between text-gray-500 mb-1">
<span>Total de Convites:</span>
<span className="font-semibold">{event.assignments.length}</span>
</div>
{pending > 0 && (
<div className="flex justify-between text-yellow-600">
<span>Aguardando resposta:</span>
<span className="font-semibold">{pending}</span>
</div>
)}
</div>
)}
</div>
);
};
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 pt-24 animate-fade-in">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900">Logística Diária</h1>
<p className="text-gray-500">Acompanhamento e escala de equipe por data da agenda</p>
</div>
<div className="flex items-center gap-3">
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<CalendarIcon size={18} className="text-gray-400" />
</div>
<input
type="date"
value={selectedDate}
onChange={handleDateChange}
className="pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-purple"
/>
</div>
<button
onClick={handleRefresh}
disabled={isLoading}
className="p-2 bg-white border border-gray-300 rounded-lg text-gray-600 hover:bg-gray-50 hover:text-brand-purple transition-all"
title="Atualizar dados"
>
<RefreshCw size={20} className={isLoading ? "animate-spin" : ""} />
</button>
</div>
</div>
{/* Dash/Metrics Panel */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
<div className="bg-white rounded-xl p-6 shadow-sm border border-gray-100 flex items-center gap-4">
<div className="p-3 bg-blue-50 text-blue-600 rounded-lg"><Users size={24}/></div>
<div>
<p className="text-sm text-gray-500 font-medium uppercase">Necessários</p>
<p className="text-2xl font-bold text-gray-900">{metrics.totalNeeded}</p>
</div>
</div>
<div className="bg-white rounded-xl p-6 shadow-sm border border-gray-100 flex items-center gap-4">
<div className="p-3 bg-green-50 text-green-600 rounded-lg"><CheckCircle size={24}/></div>
<div>
<p className="text-sm text-gray-500 font-medium uppercase">Aceitos</p>
<p className="text-2xl font-bold text-gray-900">{metrics.totalAccepted}</p>
</div>
</div>
<div className="bg-white rounded-xl p-6 shadow-sm border border-gray-100 flex items-center gap-4">
<div className="p-3 bg-yellow-50 text-yellow-600 rounded-lg"><RefreshCw size={24}/></div>
<div>
<p className="text-sm text-gray-500 font-medium uppercase">Pendentes</p>
<p className="text-2xl font-bold text-gray-900">{metrics.totalPending}</p>
</div>
</div>
<div className="bg-white rounded-xl p-6 shadow-sm border border-red-200 flex flex-col justify-center">
<div className="flex items-center gap-4 mb-2">
<div className="p-3 bg-red-50 text-red-600 rounded-lg"><AlertTriangle size={24}/></div>
<div>
<p className="text-sm text-gray-500 font-medium uppercase">Faltam (Geral)</p>
<p className="text-2xl font-bold text-red-600">{metrics.totalMissing}</p>
</div>
</div>
{/* Detailed Missing Roles */}
{metrics.totalMissing > 0 && (
<div className="flex flex-wrap gap-2 text-xs border-t border-red-100 pt-3 mt-1">
{metrics.fotMissing > 0 && (
<span className="bg-red-50 text-red-600 px-2 py-1 rounded-md font-medium">Fotógrafos: {metrics.fotMissing}</span>
)}
{metrics.recMissing > 0 && (
<span className="bg-red-50 text-red-600 px-2 py-1 rounded-md font-medium">Recepcionistas: {metrics.recMissing}</span>
)}
{metrics.cinMissing > 0 && (
<span className="bg-red-50 text-red-600 px-2 py-1 rounded-md font-medium">Cinegrafistas: {metrics.cinMissing}</span>
)}
</div>
)}
</div>
</div>
{/* Daily Invitations Section */}
{dailyInvitations.length > 0 && (
<div className="mb-6 bg-white rounded-xl shadow-sm border border-gray-100 p-5">
<div
className="flex justify-between items-center cursor-pointer mb-1 hover:bg-gray-50 p-2 -mx-2 rounded-lg transition-colors"
onClick={() => setIsInvitationsExpanded(!isInvitationsExpanded)}
>
<h3 className="font-bold text-gray-900 flex items-center gap-2">
<Users size={18} className="text-brand-purple" /> Convites Enviados ({dailyInvitations.length})
</h3>
<div className="text-gray-400 hover:text-brand-purple transition-colors">
{isInvitationsExpanded ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
</div>
</div>
{isInvitationsExpanded && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 mt-3">
{dailyInvitations.map((inv: any) => {
const prof = professionals.find(p => p.id === inv.profissional_id);
return (
<div
key={inv.id}
className="flex flex-col sm:flex-row sm:items-center justify-between bg-gray-50 border border-gray-100 rounded-lg p-3 gap-2 cursor-pointer hover:border-brand-purple/40 hover:shadow-sm transition-all"
onClick={() => {
if (prof) {
setViewingProfessional(prof);
setIsProfModalOpen(true);
}
}}
>
<div className="flex items-center gap-3">
<img src={prof?.avatar_url || `https://ui-avatars.com/api/?name=${prof?.nome || 'P'}&background=random`} alt={prof?.nome} className="w-8 h-8 rounded-full" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-semibold text-sm text-gray-900 leading-tight truncate max-w-[120px]" title={prof?.nome || 'Desconhecido'}>{prof?.nome || 'Desconhecido'}</p>
{prof?.carro_disponivel && (
<Car size={13} className="text-gray-400 shrink-0" title="Possui Carro" />
)}
</div>
<p className="text-[10px] text-brand-gold uppercase tracking-wider truncate">{prof?.role || 'Profissional'}</p>
{prof?.cidade && (
<span className={`inline-block mt-0.5 px-1.5 py-0.5 text-[9px] uppercase font-bold tracking-widest rounded border ${getCityColor(prof.cidade)}`}>
{prof.cidade}
</span>
)}
</div>
</div>
<span className={`px-2 py-1 text-[10px] font-bold rounded uppercase tracking-wider self-start sm:self-auto ${
inv.status === 'ACEITO' ? 'bg-green-100 text-green-700' :
inv.status === 'REJEITADO' ? 'bg-red-100 text-red-700' :
'bg-yellow-100 text-yellow-700'
}`}>
{inv.status}
</span>
</div>
)
})}
</div>
)}
</div>
)}
{/* Layout with Events (Left) and Available Professionals Sidebar (Right) */}
<div className="flex flex-col lg:flex-row gap-8">
{/* Left Column: Events */}
<div className="flex-1">
{isLoading ? (
<div className="flex justify-center py-20">
<RefreshCw size={32} className="text-brand-purple animate-spin" />
</div>
) : eventsInDay.length === 0 ? (
<div className="text-center py-20 bg-white rounded-2xl border border-dashed border-gray-300">
<CalendarIcon size={48} className="mx-auto text-gray-300 mb-4" />
<h3 className="text-lg font-medium text-gray-900">Nenhum evento nesta data</h3>
<p className="text-gray-500 mt-1">Selecione outro dia no calendário acima.</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{eventsInDay.map(renderEventCard)}
</div>
)}
</div>
{/* Right Column: Sticky Sidebar for Professionals */}
<div className="w-full lg:w-96 flex-shrink-0">
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 sticky top-28 flex flex-col h-[calc(100vh-140px)]">
<div className="p-5 border-b border-gray-100 bg-brand-purple/5 rounded-t-2xl">
<h2 className="text-lg font-bold text-brand-purple flex items-center gap-2">
<Users size={20} /> Lista Geral de Disponíveis
</h2>
<p className="text-sm text-gray-600 mt-1">
Profissionais com status livre no dia {selectedDate.split('-').reverse().join('/')}
</p>
</div>
<div className="p-4 border-b border-gray-100">
<input
type="text"
placeholder="Buscar pelo nome..."
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-purple outline-none"
/>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{isProsLoading ? (
<div className="flex justify-center items-center h-full">
<RefreshCw size={24} className="animate-spin text-brand-purple" />
</div>
) : paginatedProfessionals.length === 0 ? (
<div className="text-center py-10 opacity-60">
<UserPlus size={40} className="mx-auto mb-3 text-gray-400" />
<p className="text-sm text-gray-500">Nenhum profissional disponível.</p>
<p className="text-xs text-gray-400 mt-1">Todos ocupados ou não encontrados.</p>
</div>
) : (
paginatedProfessionals.map(prof => (
<div key={prof.id} className="flex flex-col gap-2 p-3 bg-gray-50 border border-gray-100 rounded-xl hover:bg-white hover:border-brand-purple/30 transition-colors">
<div className="flex items-center gap-3">
<div
className="flex items-center gap-3 flex-1 min-w-0 cursor-pointer"
onClick={() => {
setViewingProfessional(prof);
setIsProfModalOpen(true);
}}
>
<img src={prof.avatar_url || `https://ui-avatars.com/api/?name=${prof.nome}&background=random`} alt={prof.nome} className="w-10 h-10 rounded-full shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-bold text-gray-900 text-sm truncate">{prof.nome}</p>
{prof.carro_disponivel && (
<Car size={14} className="text-gray-500 shrink-0" title="Possui Carro" />
)}
</div>
<p className="text-xs text-brand-gold truncate">{prof.role || "Profissional"}</p>
{prof.cidade && (
<span className={`inline-block mt-1 px-2 py-0.5 text-[10px] uppercase font-bold tracking-wider rounded border ${getCityColor(prof.cidade)}`}>
{prof.cidade}
</span>
)}
</div>
</div>
<button
onClick={() => allocDaily(prof.id)}
className="px-4 py-2 bg-[#6E2C90] text-white font-bold rounded-lg text-xs hover:bg-[#5a2375] transition-colors whitespace-nowrap shadow-md border border-[#5a2375]"
>
Alocar
</button>
</div>
</div>
))
)}
</div>
{/* Pagination Context */}
{totalPages > 1 && (
<div className="p-3 bg-gray-50 border-t border-gray-100 flex justify-between items-center rounded-b-2xl">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
className="p-1 rounded bg-white border border-gray-300 disabled:opacity-50"
>
<ChevronLeft size={16} />
</button>
<span className="text-xs text-gray-500 font-medium">Página {page} de {totalPages}</span>
<button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="p-1 rounded bg-white border border-gray-300 disabled:opacity-50"
>
<ChevronRight size={16} />
</button>
</div>
)}
</div>
</div>
</div>
{/* Modal Profissionais */}
{isProfModalOpen && viewingProfessional && (
<ProfessionalDetailsModal
professional={viewingProfessional}
isOpen={isProfModalOpen}
onClose={() => {
setIsProfModalOpen(false);
setViewingProfessional(null);
}}
/>
)}
</div>
);
};

View file

@ -1,5 +1,5 @@
import React, { useState, useEffect, useMemo } from "react";
import { useNavigate } from 'react-router-dom';
import { useNavigate, useLocation } from 'react-router-dom';
import { UserRole, EventData, EventStatus, EventType, Professional } from "../types";
import { EventTable } from "../components/EventTable";
import { EventFiltersBar, EventFilters } from "../components/EventFiltersBar";
@ -21,8 +21,9 @@ import {
UserX,
AlertCircle,
Star,
Car, // Added Car icon
} from "lucide-react";
import { setCoordinator, finalizeFOT, getPrice } from "../services/apiService";
import { setCoordinator, finalizeFOT, getPrice, getDailyInvitationsProfessional, respondDailyInvitation, getDailyInvitationsByDate } from "../services/apiService";
import { useAuth } from "../contexts/AuthContext";
import { useData } from "../contexts/DataContext";
import { STATUS_COLORS } from "../constants";
@ -37,6 +38,7 @@ export const Dashboard: React.FC<DashboardProps> = ({
}) => {
const { user, token } = useAuth();
const navigate = useNavigate();
const location = useLocation();
// Extract updateEventDetails from useData
const {
events,
@ -54,7 +56,37 @@ export const Dashboard: React.FC<DashboardProps> = ({
refreshEvents,
} = useData();
// ... (inside component)
const [dailyInvitations, setDailyInvitations] = useState<any[]>([]);
const [isLoadingInvites, setIsLoadingInvites] = useState(false);
const fetchDailyInvitations = async () => {
if (user.role !== UserRole.PHOTOGRAPHER && user.role !== UserRole.AGENDA_VIEWER) return;
const currentProf = professionals.find(p => p.usuarioId === user.id);
if (!currentProf) return;
setIsLoadingInvites(true);
try {
const resp = await getDailyInvitationsProfessional(currentProf.id);
setDailyInvitations(resp || []);
} catch (err) {
console.error(err);
} finally {
setIsLoadingInvites(false);
}
};
useEffect(() => {
if (professionals.length > 0) fetchDailyInvitations();
}, [user, professionals]);
const handleRespondDailyInvitation = async (id: string, status: "ACEITO" | "REJEITADO") => {
try {
await respondDailyInvitation(id, status);
fetchDailyInvitations();
} catch (err) {
console.error("Error responding to invitation", err);
}
};
const handleSaveEvent = async (data: any) => {
const isClient = user.role === UserRole.EVENT_OWNER;
@ -108,12 +140,12 @@ export const Dashboard: React.FC<DashboardProps> = ({
}
};
const [view, setView] = useState<"list" | "create" | "edit" | "details">(() => {
const params = new URLSearchParams(window.location.search);
const params = new URLSearchParams(location.search);
return params.get("eventId") ? "details" : initialView;
});
const [searchTerm, setSearchTerm] = useState("");
const [selectedEvent, setSelectedEvent] = useState<EventData | null>(() => {
const params = new URLSearchParams(window.location.search);
const params = new URLSearchParams(location.search);
const eventId = params.get("eventId");
if (eventId && events.length > 0) {
return events.find(e => e.id === eventId) || null;
@ -123,17 +155,27 @@ export const Dashboard: React.FC<DashboardProps> = ({
// Effect to sync selectedEvent if events load LATER than initial render
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const params = new URLSearchParams(location.search);
const eventId = params.get("eventId");
if (eventId && events.length > 0 && !selectedEvent) {
if (eventId && events.length > 0) {
const found = events.find(e => e.id === eventId);
if (found) {
if (found && (!selectedEvent || selectedEvent.id !== eventId)) {
setSelectedEvent(found);
// Ensure view is details if we just found it
setView("details");
} else if (!found && !isLoading) {
// Se não encontrou o evento e já acabou de carregar do servidor, volta pra lista e limpa a URL
const newParams = new URLSearchParams(location.search);
newParams.delete("eventId");
navigate({ search: newParams.toString() }, { replace: true });
setSelectedEvent(null);
setView("list");
}
} else if (!eventId && selectedEvent) {
setSelectedEvent(null);
setView(initialView);
}
}, [events, window.location.search]);
}, [events, location.search, selectedEvent, initialView, isLoading, navigate]);
const [activeFilter, setActiveFilter] = useState<string>("all");
const [advancedFilters, setAdvancedFilters] = useState<EventFilters>({
date: "",
@ -150,9 +192,39 @@ export const Dashboard: React.FC<DashboardProps> = ({
const [teamRoleFilter, setTeamRoleFilter] = useState("all");
const [teamStatusFilter, setTeamStatusFilter] = useState("all");
const [teamAvailabilityFilter, setTeamAvailabilityFilter] = useState("all");
const [showOnlyDailyAccepted, setShowOnlyDailyAccepted] = useState(false);
const [eventDailyInvitations, setEventDailyInvitations] = useState<any[]>([]);
const [roleSelectionProf, setRoleSelectionProf] = useState<Professional | null>(null);
const [basePrice, setBasePrice] = useState<number | null>(null);
// Utility para cores de cidade baseado na planilha do cliente + cores dinâmicas
const getCityColor = (city: string | undefined) => {
if (!city) return 'bg-gray-100 text-gray-700 border-gray-200'; // Default
const c = city.toLowerCase().trim();
if (c.includes('campinas')) return 'bg-pink-100 text-pink-700 border-pink-200';
if (c.includes('piracicaba')) return 'bg-cyan-100 text-cyan-700 border-cyan-200';
if (c.includes('paulo') || c.includes('sp')) return 'bg-slate-200 text-slate-700 border-slate-300';
if (c.includes('americana') || c.includes('sbo') || c.includes('barbara') || c.includes('bárbara') || c.includes('santa barbara')) return 'bg-green-100 text-green-700 border-green-200';
if (c.includes('indaiatuba')) return 'bg-yellow-100 text-yellow-700 border-yellow-200';
// Dynamic color for others
const palette = [
'bg-purple-100 text-purple-700 border-purple-200',
'bg-indigo-100 text-indigo-700 border-indigo-200',
'bg-teal-100 text-teal-700 border-teal-200',
'bg-orange-100 text-orange-700 border-orange-200',
'bg-rose-100 text-rose-700 border-rose-200',
'bg-violet-100 text-violet-700 border-violet-200',
'bg-emerald-100 text-emerald-700 border-emerald-200',
'bg-fuchsia-100 text-fuchsia-700 border-fuchsia-200'
];
let hash = 0;
for (let i = 0; i < c.length; i++) {
hash = c.charCodeAt(i) + ((hash << 5) - hash);
}
return palette[Math.abs(hash) % palette.length];
};
useEffect(() => {
const fetchBasePrice = async () => {
if (!selectedEvent || !user || !token) {
@ -244,11 +316,12 @@ export const Dashboard: React.FC<DashboardProps> = ({
useEffect(() => {
if (initialView) {
const params = new URLSearchParams(location.search);
if (initialView && !params.get("eventId")) {
setView(initialView);
if (initialView === "create") setSelectedEvent(null);
}
}, [initialView]);
}, [initialView, location.search]);
const handleViewProfessional = (professional: Professional) => {
setViewingProfessional(professional);
@ -362,6 +435,20 @@ export const Dashboard: React.FC<DashboardProps> = ({
}
};
useEffect(() => {
const fetchEventDailyInvitations = async () => {
if (isTeamModalOpen && selectedEvent && selectedEvent.date) {
try {
const res = await getDailyInvitationsByDate(selectedEvent.date.split('T')[0]);
setEventDailyInvitations(res || []);
} catch(err) {
console.error("Erro ao buscar convites do dia do evento:", err);
}
}
};
fetchEventDailyInvitations();
}, [isTeamModalOpen, selectedEvent]);
// Função para fechar modal de equipe e limpar filtros
const closeTeamModal = () => {
setIsTeamModalOpen(false);
@ -369,6 +456,7 @@ export const Dashboard: React.FC<DashboardProps> = ({
setTeamRoleFilter("all");
setTeamStatusFilter("all");
setTeamAvailabilityFilter("all");
setShowOnlyDailyAccepted(false);
};
const [processingIds, setProcessingIds] = useState<Set<string>>(new Set());
@ -454,6 +542,12 @@ export const Dashboard: React.FC<DashboardProps> = ({
}
}
// Filtro "Confirmados pro Plantão" Exclusivo
if (showOnlyDailyAccepted) {
const hasAcceptedDaily = eventDailyInvitations.some(inv => inv.profissional_id === professional.id && inv.status === 'ACEITO');
if (!hasAcceptedDaily) return false;
}
return true;
});
}, [
@ -464,6 +558,8 @@ export const Dashboard: React.FC<DashboardProps> = ({
teamRoleFilter,
teamStatusFilter,
teamAvailabilityFilter,
eventDailyInvitations,
showOnlyDailyAccepted,
busyProfessionalIds // Depende apenas do Set otimizado
]);
@ -827,7 +923,7 @@ export const Dashboard: React.FC<DashboardProps> = ({
<div className="min-h-screen bg-gray-50 pt-20 sm:pt-24 md:pt-28 lg:pt-32 pb-8 sm:pb-12 px-3 sm:px-4 lg:px-6">
<div className="max-w-7xl mx-auto">
{/* Header */}
{view === "list" && !new URLSearchParams(window.location.search).get("eventId") && (
{view === "list" && !new URLSearchParams(location.search).get("eventId") && (
<div className="flex flex-col md:flex-row md:items-center justify-between mb-6 sm:mb-8 gap-3 sm:gap-4 fade-in">
{renderRoleSpecificHeader()}
{renderRoleSpecificActions()}
@ -835,8 +931,80 @@ export const Dashboard: React.FC<DashboardProps> = ({
)}
{/* Content Switcher */}
{view === "list" && !new URLSearchParams(window.location.search).get("eventId") && (
{view === "list" && !new URLSearchParams(location.search).get("eventId") && (
<div className="space-y-6 fade-in">
{user.role === UserRole.PHOTOGRAPHER && dailyInvitations.some(c => c.status === 'PENDENTE') && (
<div className="bg-white border rounded-lg overflow-hidden shadow-sm border-brand-purple/20 mb-6">
<div className="bg-gradient-to-r from-brand-purple/10 to-transparent p-4 border-b border-brand-purple/10">
<h3 className="font-bold text-brand-purple flex items-center gap-2">
<Calendar size={18} /> Convites Pendentes
</h3>
<p className="text-xs text-gray-600 mt-1">Você foi selecionado para atuar nestas datas. Confirme sua disponibilidade.</p>
</div>
<div className="divide-y divide-gray-100">
{dailyInvitations.filter(c => c.status === 'PENDENTE').map(inv => (
<div key={inv.id} className="p-4 flex flex-col sm:flex-row items-center justify-between gap-4 hover:bg-gray-50/50">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-brand-purple/10 text-brand-purple flex items-center justify-center font-bold">
{inv.data ? inv.data.split('-')[2] : '--'}
</div>
<div>
<p className="font-bold text-gray-900">{inv.data ? inv.data.split('-').reverse().join('/') : ''}</p>
<p className="text-xs text-brand-gold">Plantão Diário</p>
</div>
</div>
<div className="flex gap-2 w-full sm:w-auto">
<Button
variant="outline"
onClick={() => handleRespondDailyInvitation(inv.id, 'REJEITADO')}
className="flex-1 sm:flex-none text-red-600 border-red-200 hover:bg-red-50 hover:border-red-300"
>
<X size={16} className="mr-1" /> Recusar
</Button>
<Button
onClick={() => handleRespondDailyInvitation(inv.id, 'ACEITO')}
className="flex-1 sm:flex-none bg-brand-purple hover:bg-brand-purple-dark text-white"
>
<CheckCircle size={16} className="mr-1" /> Confirmar
</Button>
</div>
</div>
))}
</div>
</div>
)}
{user.role === UserRole.PHOTOGRAPHER && dailyInvitations.some(c => c.status === 'ACEITO') && (
<div className="bg-white border rounded-lg overflow-hidden shadow-sm border-brand-gold/20 mb-6">
<div className="bg-gradient-to-r from-brand-gold/10 to-transparent p-4 border-b border-brand-gold/10">
<h3 className="font-bold text-brand-gold flex items-center gap-2">
<CheckCircle size={18} /> Datas Confirmadas
</h3>
<p className="text-xs text-gray-600 mt-1">Sua presença está confirmada nestas datas. Aguarde a alocação num evento específico e o envio do briefing.</p>
</div>
<div className="divide-y divide-gray-100">
{dailyInvitations.filter(c => c.status === 'ACEITO').map(inv => (
<div key={inv.id} className="p-4 flex flex-col sm:flex-row items-center justify-between gap-4 hover:bg-gray-50/50">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-brand-gold/10 text-brand-gold flex items-center justify-center font-bold">
{inv.data ? inv.data.split('-')[2] : '--'}
</div>
<div>
<p className="font-bold text-gray-900">{inv.data ? inv.data.split('-').reverse().join('/') : ''}</p>
<p className="text-xs text-brand-purple mt-0.5 font-medium"><Clock size={12} className="inline mr-1 mb-0.5"/> Aguardando Definição de Local Turma</p>
</div>
</div>
<div className="flex gap-2 w-full sm:w-auto">
<span className="px-3 py-1 flex items-center text-sm font-bold bg-green-100 text-green-700 rounded-lg whitespace-nowrap">
<CheckCircle size={16} className="mr-2" /> Agendado
</span>
</div>
</div>
))}
</div>
</div>
)}
{/* Search Bar */}
<div className="flex flex-col sm:flex-row gap-4 items-center justify-between bg-gray-50 p-3 rounded-lg border border-gray-100">
<div className="relative flex-1 w-full">
@ -899,8 +1067,7 @@ export const Dashboard: React.FC<DashboardProps> = ({
<EventTable
events={filteredEvents}
onEventClick={(event) => {
setSelectedEvent(event);
setView("details");
navigate(`?eventId=${event.id}`);
}}
onApprove={handleApprove}
onReject={handleReject}
@ -926,7 +1093,7 @@ export const Dashboard: React.FC<DashboardProps> = ({
{/* Loading State for Deep Link */ }
{(!!new URLSearchParams(window.location.search).get("eventId") && (!selectedEvent || view !== "details")) && (
{(!!new URLSearchParams(location.search).get("eventId") && !selectedEvent) && (
<div className="flex flex-col items-center justify-center py-20 fade-in">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-purple mb-4"></div>
<p className="text-gray-500">Carregando detalhes do evento...</p>
@ -937,7 +1104,12 @@ export const Dashboard: React.FC<DashboardProps> = ({
<div className="fade-in">
<Button
variant="ghost"
onClick={() => setView("list")}
onClick={() => {
const newParams = new URLSearchParams(location.search);
newParams.delete("eventId");
navigate({ search: newParams.toString() });
setView("list");
}}
className="mb-4 pl-0"
>
Voltar para lista
@ -1761,7 +1933,7 @@ export const Dashboard: React.FC<DashboardProps> = ({
{/* Body */}
<div className="flex-1 overflow-auto p-6">
<div className="mb-4">
<p className="text-sm text-gray-600">
<p className="text-sm text-gray-600 mb-2">
Profissionais disponíveis para a data{" "}
<strong>
{new Date(
@ -1770,6 +1942,16 @@ export const Dashboard: React.FC<DashboardProps> = ({
</strong>
. Clique em "Adicionar" para atribuir ao evento.
</p>
{/* Totalizador de Plantão */}
{eventDailyInvitations.length > 0 && (
<div className="flex items-center gap-2 bg-brand-gold/10 text-brand-gold-dark border border-brand-gold/20 p-3 rounded-lg">
<CheckCircle size={16} className="text-brand-gold" />
<span className="text-sm font-medium">
{eventDailyInvitations.filter(i => i.status === 'ACEITO').length} profissional(is) confirmou(aram) plantão para este dia na Logística Diária.
</span>
</div>
)}
</div>
{/* Filtros e Busca */}
@ -1824,14 +2006,26 @@ export const Dashboard: React.FC<DashboardProps> = ({
<option value="unavailable">Indisponíveis</option>
</select>
{/* Toggle Confirmados no Plantão */}
<label className={`flex items-center gap-2 px-3 py-2 border rounded-lg cursor-pointer transition-colors text-sm ${showOnlyDailyAccepted ? 'bg-brand-gold/10 text-brand-gold border-brand-gold/30' : 'bg-white text-gray-600 border-gray-300 hover:bg-gray-50'}`}>
<input
type="checkbox"
className="rounded text-brand-gold focus:ring-brand-gold bg-white border-gray-300"
checked={showOnlyDailyAccepted}
onChange={(e) => setShowOnlyDailyAccepted(e.target.checked)}
/>
<span className="font-semibold">Confirmados no Plantão</span>
</label>
{/* Botão limpar filtros */}
{(teamSearchTerm || teamRoleFilter !== "all" || teamStatusFilter !== "all" || teamAvailabilityFilter !== "all") && (
{(teamSearchTerm || teamRoleFilter !== "all" || teamStatusFilter !== "all" || teamAvailabilityFilter !== "all" || showOnlyDailyAccepted) && (
<button
onClick={() => {
setTeamSearchTerm("");
setTeamRoleFilter("all");
setTeamStatusFilter("all");
setTeamAvailabilityFilter("all");
setShowOnlyDailyAccepted(false);
}}
className="px-3 py-2 text-sm text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition-colors flex items-center gap-1"
>
@ -1915,13 +2109,25 @@ export const Dashboard: React.FC<DashboardProps> = ({
</div>
)}
</div>
<div>
<p className="font-semibold text-gray-800 text-sm">
{photographer.name || photographer.nome}
</p>
<p className="text-xs text-gray-500">
ID: {photographer.id.substring(0, 8)}...
</p>
<div className="flex-1">
<div className="flex items-center gap-2">
<p className="font-semibold text-gray-800 text-sm">
{photographer.name || photographer.nome}
</p>
{photographer.carro_disponivel && (
<Car size={14} className="text-gray-500 shrink-0" title="Possui Carro" />
)}
</div>
<div className="flex items-center gap-2 mt-0.5">
<p className="text-xs text-gray-500">
ID: {photographer.id.substring(0, 8)}...
</p>
{photographer.cidade && (
<span className={`inline-block px-1.5 py-0.5 text-[9px] uppercase font-bold tracking-widest rounded border ${getCityColor(photographer.cidade)}`}>
{photographer.cidade}
</span>
)}
</div>
</div>
</div>
</td>
@ -2070,9 +2276,21 @@ export const Dashboard: React.FC<DashboardProps> = ({
</div>
)}
</div>
<div>
<p className="font-medium text-gray-800 text-sm">{photographer.name || photographer.nome}</p>
<p className="text-xs text-gray-500">{assignment?.status === "PENDENTE" ? "Convite Pendente" : assignment?.status}</p>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium text-gray-800 text-sm truncate">{photographer.name || photographer.nome}</p>
{photographer.carro_disponivel && (
<Car size={14} className="text-gray-500 shrink-0" title="Possui Carro" />
)}
</div>
<div className="flex items-center gap-2 mt-0.5">
<p className="text-xs text-gray-500 truncate">{assignment?.status === "PENDENTE" ? "Convite Pendente" : assignment?.status}</p>
{photographer.cidade && (
<span className={`inline-block px-1.5 py-0.5 text-[9px] uppercase font-bold tracking-widest rounded border ${getCityColor(photographer.cidade)}`}>
{photographer.cidade}
</span>
)}
</div>
</div>
</div>

View file

@ -11,6 +11,8 @@ import {
Upload,
} from "lucide-react";
import { useNavigate } from "react-router-dom";
import ExcelJS from 'exceljs';
import { saveAs } from 'file-saver';
interface FinancialTransaction {
id: string;
@ -302,7 +304,17 @@ const Finance: React.FC = () => {
// Default sort is grouped by FOT
if (!sortConfig) {
return result.sort((a, b) => {
// Group by FOT (String comparison to handle "20000MG")
// Group by Professional Name if Date Filters are active
if (dateFilters.startDate || dateFilters.endDate) {
const nameA = String(a.nome || "").toLowerCase();
const nameB = String(b.nome || "").toLowerCase();
if (nameA !== nameB) return nameA.localeCompare(nameB);
// Secondary sort by date within the same professional
return new Date(a.dataRaw || a.data).getTime() - new Date(b.dataRaw || b.data).getTime();
}
// Default Group by FOT (String comparison to handle "20000MG")
const fotA = String(a.fot || "");
const fotB = String(b.fot || "");
if (fotA !== fotB) return fotB.localeCompare(fotA, undefined, { numeric: true });
@ -815,58 +827,232 @@ const Finance: React.FC = () => {
}, [formData.tipoEvento, formData.tipoServico, formData.tabelaFree]);
const handleExportCSV = () => {
const handleExportExcel = async () => {
if (sortedTransactions.length === 0) {
alert("Nenhum dado para exportar.");
return;
}
// CSV Header
const headers = [
"FOT", "Data", "Evento", "Serviço", "Nome", "WhatsApp", "CPF",
"Curso", "Instituição", "Ano", "Empresa",
"Valor Free", "Valor Extra", "Total Pagar", "Data Pagamento", "Pgto OK"
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet("Extrato Financeiro");
// Columns matching the legacy Excel sheet
worksheet.columns = [
{ header: "", key: "fot", width: 10 },
{ header: "", key: "data", width: 12 },
{ header: "", key: "curso", width: 20 },
{ header: "", key: "instituicao", width: 20 },
{ header: "", key: "ano", width: 10 },
{ header: "", key: "empresa", width: 15 },
{ header: "", key: "evento", width: 15 },
{ header: "", key: "servico", width: 15 },
{ header: "", key: "nome", width: 25 },
{ header: "", key: "whatsapp", width: 15 },
{ header: "", key: "cpf", width: 16 },
{ header: "", key: "tabelaFree", width: 15 },
{ header: "", key: "valorFree", width: 15 },
{ header: "", key: "valorExtra", width: 15 },
{ header: "", key: "descricaoExtra", width: 40 },
{ header: "", key: "totalPagar", width: 15 },
{ header: "", key: "dataPgto", width: 12 },
{ header: "", key: "pgtoOk", width: 10 },
];
// CSV Rows
const rows = sortedTransactions.map(t => [
t.fot,
t.data,
t.tipoEvento,
t.tipoServico,
`"${t.nome}"`, // Quote names to handle commas
t.whatsapp ? `="${t.whatsapp}"` : "", // Force string in Excel to avoid scientific notation
t.cpf ? `="${t.cpf}"` : "",
`"${t.curso}"`,
`"${t.instituicao}"`,
t.anoFormatura,
`"${t.empresa}"`,
t.valorFree.toFixed(2).replace('.', ','),
t.valorExtra.toFixed(2).replace('.', ','),
t.totalPagar.toFixed(2).replace('.', ','),
t.dataPgto,
t.pgtoOk ? "Sim" : "Não"
// Standard Headers (Row 1 now)
const headerRow = worksheet.addRow([
"FOT", "Data", "Curso", "Instituição", "Ano Format.", "Empresa", "Tipo Evento", "Tipo de Serviço",
"Nome", "WhatsApp", "CPF", "Tabela Free", "Valor Free", "Valor Extra",
"Descrição do Extra", "Total a Pagar", "Data Pgto", "Pgto OK"
]);
// Summation Row
// Header Styling (Red #FF0000 based on screenshot 3)
headerRow.eachCell((cell, colNumber) => {
cell.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFFF0000' } // Red Header
};
cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true };
// Even the header in the screenshot has Yellow for Total? The screenshot 3 shows pure blue/grey for table headers, let's use the standard blue that matches their columns
// Looking at screenshot 3, the columns header is actually a light blue! "A4:R4"
cell.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FF9BC2E6' } // Light Blue from standard excel
};
cell.font = { bold: true, color: { argb: 'FF000000' }, size: 10 }; // Black text on blue
if (worksheet.getColumn(colNumber).key === 'totalPagar') {
cell.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFFFFF00' }
};
}
});
let currentName = "";
let groupSum = 0;
const applyRowColor = (row: ExcelJS.Row) => {
for (let colNumber = 1; colNumber <= 18; colNumber++) {
const cell = row.getCell(colNumber);
const colKey = worksheet.getColumn(colNumber).key as string;
cell.border = {
top: {style:'thin'},
left: {style:'thin'},
bottom: {style:'thin'},
right: {style:'thin'}
};
cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true };
if (colKey === 'descricaoExtra' || colKey === 'nome') {
cell.alignment = { vertical: 'middle', horizontal: 'left', wrapText: true };
}
if (colKey === 'totalPagar') {
cell.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFFFFF00' } // Yellow column
};
}
if (["valorFree", "valorExtra", "totalPagar"].includes(colKey)) {
cell.alignment = { vertical: 'middle', horizontal: 'right', wrapText: true };
cell.numFmt = '"R$" #,##0.00';
}
}
};
if (dateFilters.startDate || dateFilters.endDate) {
sortedTransactions.forEach((t, i) => {
const tName = t.nome || "Sem Nome";
if (currentName !== "" && tName !== currentName) {
// Subtotal Row
const subRow = worksheet.addRow({
descricaoExtra: `SUBTOTAL ${currentName}`,
totalPagar: groupSum
});
subRow.font = { bold: true };
for (let colNumber = 1; colNumber <= 18; colNumber++) {
const cell = subRow.getCell(colNumber);
const colKey = worksheet.getColumn(colNumber).key as string;
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFFF00' } }; // Entire row yellow!
cell.border = { top: {style:'thin'}, left: {style:'thin'}, bottom: {style:'thin'}, right: {style:'thin'} };
cell.alignment = { vertical: 'middle', horizontal: 'right' };
if (["valorFree", "valorExtra", "totalPagar"].includes(colKey)) {
cell.numFmt = '"R$" #,##0.00';
}
}
groupSum = 0;
}
if (currentName === "") {
currentName = tName;
}
currentName = tName;
groupSum += t.totalPagar;
const row = worksheet.addRow({
fot: t.fot,
data: t.data,
curso: t.curso,
instituicao: t.instituicao,
ano: t.anoFormatura,
empresa: t.empresa,
evento: t.tipoEvento,
servico: t.tipoServico,
nome: t.nome,
whatsapp: t.whatsapp,
cpf: t.cpf,
tabelaFree: t.tabelaFree,
valorFree: t.valorFree,
valorExtra: t.valorExtra,
descricaoExtra: t.descricaoExtra,
totalPagar: t.totalPagar,
dataPgto: t.dataPgto,
pgtoOk: t.pgtoOk ? "Sim" : "Não"
});
applyRowColor(row);
// Final subtotal
if (i === sortedTransactions.length - 1) {
const subRow = worksheet.addRow({
descricaoExtra: `SUBTOTAL ${currentName}`,
totalPagar: groupSum
});
subRow.font = { bold: true };
for (let colNumber = 1; colNumber <= 18; colNumber++) {
const cell = subRow.getCell(colNumber);
const colKey = worksheet.getColumn(colNumber).key as string;
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFFF00' } };
cell.border = { top: {style:'thin'}, left: {style:'thin'}, bottom: {style:'thin'}, right: {style:'thin'} };
cell.alignment = { vertical: 'middle', horizontal: 'right' };
if (["valorFree", "valorExtra", "totalPagar"].includes(colKey)) {
cell.numFmt = '"R$" #,##0.00';
}
}
}
});
} else {
// Flat standard export without grouped subtotals if not filtering dates
sortedTransactions.forEach(t => {
const row = worksheet.addRow({
fot: t.fot,
data: t.data,
evento: t.tipoEvento,
servico: t.tipoServico,
nome: t.nome,
whatsapp: t.whatsapp,
cpf: t.cpf,
curso: t.curso,
instituicao: t.instituicao,
ano: t.anoFormatura,
empresa: t.empresa,
tabelaFree: t.tabelaFree,
valorFree: t.valorFree,
valorExtra: t.valorExtra,
descricaoExtra: t.descricaoExtra,
totalPagar: t.totalPagar,
dataPgto: t.dataPgto,
pgtoOk: t.pgtoOk ? "Sim" : "Não"
});
applyRowColor(row);
});
}
// Global Total Row
const totalValue = sortedTransactions.reduce((sum, t) => sum + t.totalPagar, 0);
const sumRow = [
"TOTAL", "", "", "", "", "", "", "", "", "", "", "", "",
totalValue.toFixed(2).replace('.', ','), "", ""
];
const sumRow = worksheet.addRow({
descricaoExtra: "TOTAL GERAL",
totalPagar: totalValue
});
sumRow.font = { bold: true, size: 12 };
for (let colNumber = 1; colNumber <= 18; colNumber++) {
const cell = sumRow.getCell(colNumber);
const colKey = worksheet.getColumn(colNumber).key as string;
// Combine
const csvContent = [
headers.join(";"),
...rows.map(r => r.join(";")),
sumRow.join(";")
].join("\n");
if (colKey === 'totalPagar' || colKey === 'descricaoExtra') {
cell.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFFFFF00' } // Yellow
};
if (colKey === 'totalPagar') {
cell.alignment = { vertical: 'middle', horizontal: 'right' };
cell.numFmt = '"R$" #,##0.00';
} else {
cell.alignment = { vertical: 'middle', horizontal: 'right' };
}
}
}
// Create Blob with BOM for Excel UTF-8 support
const blob = new Blob(["\uFEFF" + csvContent], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
// Dynamic Filename
// Save
let filename = "extrato_financeiro";
if (filters.nome) {
filename += `_${filters.nome.trim().replace(/\s+/g, '_').toLowerCase()}`;
@ -883,21 +1069,15 @@ const Finance: React.FC = () => {
if (dateFilters.endDate) {
filename += `_ate_${formatDateFilename(dateFilters.endDate)}`;
}
// Fallback or just append timestamp if no specific filters?
// Let's always append a short date/time or just date if no filters to avoid overwrites,
// but user asked for specific format. Let's stick to user request + date fallback if empty.
if (!filters.nome && !dateFilters.startDate && !dateFilters.endDate) {
const today = new Date().toLocaleDateString("pt-BR").split("/").join("-"); // DD-MM-YYYY
const today = new Date().toLocaleDateString("pt-BR").split("/").join("-");
filename += `_${today}`;
}
filename += ".csv";
filename += ".xlsx";
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", filename);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
const buffer = await workbook.xlsx.writeBuffer();
const blob = new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
saveAs(blob, filename);
};
// Calculate Total for Display
@ -913,7 +1093,7 @@ const Finance: React.FC = () => {
</div>
<div className="flex gap-2">
<button
onClick={handleExportCSV}
onClick={handleExportExcel}
className="bg-green-600 text-white px-4 py-2 rounded shadow hover:bg-green-700 transition flex items-center gap-2"
>
<Download size={18} /> Exportar Dados
@ -1147,17 +1327,26 @@ const Finance: React.FC = () => {
</tr>
)}
{!loading && sortedTransactions.map((t, index) => {
const isNewFot = index > 0 && t.fot !== sortedTransactions[index - 1].fot;
const isDateFilterActive = !!(dateFilters.startDate || dateFilters.endDate);
const isNewGroup = isDateFilterActive
? index > 0 && t.nome !== sortedTransactions[index - 1].nome
: index > 0 && t.fot !== sortedTransactions[index - 1].fot;
// Check if this is the last item of the group (or list) to show summary
const isLastOfGroup = index === sortedTransactions.length - 1 || t.fot !== sortedTransactions[index + 1].fot;
// Only show summary if sorted by default (which groups by FOT) or explicitly sorted by FOT
const showSummary = isLastOfGroup && (!sortConfig || sortConfig.key === 'fot');
const isLastOfGroup = isDateFilterActive
? index === sortedTransactions.length - 1 || t.nome !== sortedTransactions[index + 1].nome
: index === sortedTransactions.length - 1 || t.fot !== sortedTransactions[index + 1].fot;
// Only show summary if sorted by default (which groups by FOT) or explicitly sorted by FOT,
// OR if Date Filters are active (which groups by Professional Name)
const showSummary = isLastOfGroup && (
(!sortConfig || sortConfig.key === 'fot') || isDateFilterActive
);
return (
<React.Fragment key={t.id}>
<tr
className={`hover:bg-gray-50 cursor-pointer ${isNewFot ? "border-t-[3px] border-gray-400" : ""} ${selectedIds.has(t.id!) ? "bg-blue-50" : ""}`}
className={`hover:bg-gray-50 cursor-pointer ${isNewGroup ? "border-t-[3px] border-gray-400" : ""} ${selectedIds.has(t.id!) ? "bg-blue-50" : ""}`}
onClick={() => handleEdit(t)}
>
<td className="px-3 py-2 text-center" onClick={(e) => e.stopPropagation()}>
@ -1205,12 +1394,12 @@ const Finance: React.FC = () => {
{showSummary && (
<tr className="bg-gray-100 font-bold text-gray-800 border-b-2 border-gray-300">
<td colSpan={11} className="px-3 py-2 text-right uppercase text-[10px] tracking-wide text-gray-500">
Total FOT {t.fot}:
{isDateFilterActive ? `Subtotal ${t.nome}:` : `Total FOT ${t.fot}:`}
</td>
<td className="px-3 py-2 text-right text-brand-gold">
{/* Calculate sum for this group */}
{sortedTransactions
.filter(tr => tr.fot === t.fot)
.filter(tr => isDateFilterActive ? tr.nome === t.nome : tr.fot === t.fot)
.reduce((sum, curr) => sum + (curr.totalPagar || 0), 0)
.toFixed(2)}
</td>

View file

@ -10,6 +10,7 @@ import {
Camera,
Video,
UserCheck,
X,
} from "lucide-react";
import { Button } from "../components/Button";
import {
@ -21,10 +22,12 @@ import { useAuth } from "../contexts/AuthContext";
import { Professional } from "../types";
import { ProfessionalDetailsModal } from "../components/ProfessionalDetailsModal";
import { ProfessionalModal } from "../components/ProfessionalModal";
import { useData } from "../contexts/DataContext";
export const TeamPage: React.FC = () => {
const { token: contextToken } = useAuth();
const token = contextToken || "";
const { events } = useData(); // Needed to check assignments
// Lists
const [professionals, setProfessionals] = useState<Professional[]>([]);
@ -37,6 +40,7 @@ export const TeamPage: React.FC = () => {
const [searchTerm, setSearchTerm] = useState("");
const [roleFilter, setRoleFilter] = useState("all");
const [ratingFilter, setRatingFilter] = useState("all");
const [dateFilter, setDateFilter] = useState(""); // "" means All (Lista Livre)
// Selection & Modals
const [selectedProfessional, setSelectedProfessional] = useState<Professional | null>(null);
@ -131,11 +135,26 @@ export const TeamPage: React.FC = () => {
}
})();
const matchesDate = (() => {
if (!dateFilter) return true; // Lista livre
// Find if the professional has an accepted assignment on this date
const isAcceptedOnDate = events.some(e => {
if (!e.date || !e.date.startsWith(dateFilter)) return false;
return e.assignments?.some(a =>
a.professionalId === p.id &&
(a.status === "ACEITO" || a.status === "CONFIRMADO" || a.status === "aceito" || a.status === "confirmado")
);
});
return isAcceptedOnDate;
})();
// if (roleName === "Desconhecido") return false;
return matchesSearch && matchesRole && matchesRating;
return matchesSearch && matchesRole && matchesRating && matchesDate;
});
}, [professionals, searchTerm, roleFilter, ratingFilter, roles]);
}, [professionals, searchTerm, roleFilter, ratingFilter, dateFilter, events, roles]);
return (
<div className="min-h-screen bg-gray-50 pt-20 sm:pt-24 md:pt-28 lg:pt-32 pb-8 sm:pb-12">
@ -207,36 +226,64 @@ export const TeamPage: React.FC = () => {
</Button>
</div>
<div className="flex flex-col md:flex-row gap-4 items-center">
<div className="flex items-center gap-2">
<Filter size={16} className="text-gray-400" />
<span className="text-sm font-medium text-gray-700">Filtros:</span>
<div className="flex flex-col xl:flex-row gap-4 xl:items-center justify-between">
<div className="flex flex-col md:flex-row gap-4 items-center">
<div className="flex items-center gap-2">
<Filter size={16} className="text-gray-400" />
<span className="text-sm font-medium text-gray-700">Filtros:</span>
</div>
<select
value={roleFilter}
onChange={(e) => setRoleFilter(e.target.value)}
className="px-3 py-1 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-brand-gold"
>
<option value="all">Todas as Funções</option>
{roles.map(role => (
<option key={role.id} value={role.nome}>{role.nome}</option>
))}
</select>
<select
value={ratingFilter}
onChange={(e) => setRatingFilter(e.target.value)}
className="px-3 py-1 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-brand-gold"
>
<option value="all">Todas as Avaliações</option>
<option value="5"> 4.5+ Estrelas</option>
<option value="4"> 4.0 - 4.4 Estrelas</option>
<option value="3"> 3.0 - 3.9 Estrelas</option>
<option value="2"> 2.0 - 2.9 Estrelas</option>
<option value="1"> 1.0 - 1.9 Estrelas</option>
<option value="0"> Menos de 1.0</option>
</select>
</div>
<select
value={roleFilter}
onChange={(e) => setRoleFilter(e.target.value)}
className="px-3 py-1 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-brand-gold"
>
<option value="all">Todas as Funções</option>
{roles.map(role => (
<option key={role.id} value={role.nome}>{role.nome}</option>
))}
</select>
<select
value={ratingFilter}
onChange={(e) => setRatingFilter(e.target.value)}
className="px-3 py-1 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-brand-gold"
>
<option value="all">Todas as Avaliações</option>
<option value="5"> 4.5+ Estrelas</option>
<option value="4"> 4.0 - 4.4 Estrelas</option>
<option value="3"> 3.0 - 3.9 Estrelas</option>
<option value="2"> 2.0 - 2.9 Estrelas</option>
<option value="1"> 1.0 - 1.9 Estrelas</option>
<option value="0"> Menos de 1.0</option>
</select>
<div className="flex flex-col sm:flex-row gap-4 sm:items-center border-t sm:border-t-0 pt-4 sm:pt-0 border-gray-200 xl:border-l xl:pl-4">
<div className="text-sm font-medium text-gray-700 whitespace-nowrap">Status Refinado:</div>
<div className="flex gap-2 items-center bg-gray-50 p-1 rounded-lg border border-gray-200">
<button
onClick={() => setDateFilter("")}
className={`px-3 py-1 text-sm rounded-md transition-all ${dateFilter === "" ? 'bg-white shadow-sm font-semibold text-brand-black' : 'text-gray-500 hover:text-gray-700'}`}
>
Lista Livre (Todos)
</button>
<div className="flex items-center">
<input
type="date"
className={`px-3 py-1 text-sm rounded-md border-0 bg-transparent transition-all focus:ring-0 cursor-pointer ${dateFilter !== "" ? 'bg-brand-purple text-white shadow-sm font-semibold' : 'text-gray-500 hover:text-gray-700 hover:bg-white'}`}
value={dateFilter}
onChange={(e) => setDateFilter(e.target.value)}
title="Selecione um dia para ver apenas os aceitos"
/>
{dateFilter && (
<button onClick={() => setDateFilter("")} className="ml-1 p-1 text-red-300 hover:text-red-500 rounded-full" title="Limpar Data">
<X size={14} />
</button>
)}
</div>
</div>
</div>
</div>
</div>
</div>

View file

@ -1502,3 +1502,119 @@ export const setCoordinator = async (token: string, eventId: string, professiona
return { error: "Network error" };
}
};
// --------------- LOGISTICS DAILY INVITATIONS --------------- //
export async function getAvailableProfessionalsLogistics(date: string, page: number = 1, limit: number = 50, search: string = "") {
try {
const region = localStorage.getItem("photum_selected_region") || "SP";
const query = new URLSearchParams({
data: date,
page: page.toString(),
limit: limit.toString(),
search: search
});
const response = await apiFetch(`${API_BASE_URL}/api/logistica/disponiveis?${query.toString()}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
"x-regiao": region,
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error(`Error fetching daily professionals:`, error);
return { data: [], total: 0, page: 1, total_pages: 1 };
}
}
export async function createDailyInvitation(profissionalId: string, data: string) {
try {
const region = localStorage.getItem("photum_selected_region") || "SP";
const response = await apiFetch(`${API_BASE_URL}/api/logistica/convites`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-regiao": region,
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
body: JSON.stringify({ profissional_id: profissionalId, data: data }),
});
if (!response.ok) {
const errData = await response.json().catch(() => ({}));
throw new Error(errData.error || `Failed to create invitation (status ${response.status})`);
}
return await response.json();
} catch (error) {
console.error(`Error creating daily invitation:`, error);
throw error;
}
}
export async function getDailyInvitationsProfessional(profId: string) {
try {
const region = localStorage.getItem("photum_selected_region") || "SP";
const response = await apiFetch(`${API_BASE_URL}/api/logistica/convites?prof_id=${profId}`, {
method: "GET",
headers: {
"x-regiao": region,
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
});
if (!response.ok) throw new Error("Failed to fetch daily invitations");
return await response.json();
} catch (error) {
console.error(`Error fetching daily invitations:`, error);
return [];
}
}
export async function respondDailyInvitation(invitationId: string, status: "ACEITO" | "REJEITADO", reason?: string) {
try {
const region = localStorage.getItem("photum_selected_region") || "SP";
const response = await apiFetch(`${API_BASE_URL}/api/logistica/convites/${invitationId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
"x-regiao": region,
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
body: JSON.stringify({ status, motivo_rejeicao: reason || "" }),
});
if (!response.ok) throw new Error("Failed to respond to invitation");
return await response.json();
} catch (error) {
console.error(`Error responding daily invitation:`, error);
throw error;
}
}
export async function getDailyInvitationsByDate(date: string) {
try {
const region = localStorage.getItem("photum_selected_region") || "SP";
const response = await apiFetch(`${API_BASE_URL}/api/logistica/convites-por-data?data=${date}`, {
method: "GET",
headers: {
"x-regiao": region,
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
});
if (!response.ok) throw new Error("Failed to fetch daily invitations by date");
return await response.json();
} catch (error) {
console.error(`Error fetching daily invitations by date:`, error);
return [];
}
}