feat(agenda): Implementação completa da Importação de Agenda e melhorias de UX
- Backend: Implementada lógica de importação de Agenda (Upsert) em `internal/agenda`. - Backend: Criadas queries SQL para busca de FOT e Tipos de Evento. - Frontend: Adicionada aba de Importação de Agenda em `ImportData.tsx`. - Frontend: Implementado Parser de Excel para Agenda com tratamento de datas. - UX: Adicionada Barra de Rolagem Superior Sincronizada na Tabela de Eventos. - UX: Implementado `LoadingScreen` global unificado (Auth + DataContext). - Perf: Adicionada Paginação no `EventTable` para resolver travamentos com grandes listas. - Security: Proteção de rotas de importação (RequireWriteAccess).
This commit is contained in:
parent
60155bdf56
commit
a6ba63203a
13 changed files with 697 additions and 94 deletions
|
|
@ -223,6 +223,7 @@ func main() {
|
||||||
api.PATCH("/agenda/:id/professionals/:profId/position", auth.RequireWriteAccess(), agendaHandler.UpdateAssignmentPosition)
|
api.PATCH("/agenda/:id/professionals/:profId/position", auth.RequireWriteAccess(), agendaHandler.UpdateAssignmentPosition)
|
||||||
api.PATCH("/agenda/:id/status", auth.RequireWriteAccess(), agendaHandler.UpdateStatus)
|
api.PATCH("/agenda/:id/status", auth.RequireWriteAccess(), agendaHandler.UpdateStatus)
|
||||||
api.POST("/agenda/:id/notify-logistics", auth.RequireWriteAccess(), agendaHandler.NotifyLogistics)
|
api.POST("/agenda/:id/notify-logistics", auth.RequireWriteAccess(), agendaHandler.NotifyLogistics)
|
||||||
|
api.POST("/import/agenda", auth.RequireWriteAccess(), agendaHandler.Import)
|
||||||
|
|
||||||
api.POST("/availability", availabilityHandler.SetAvailability)
|
api.POST("/availability", availabilityHandler.SetAvailability)
|
||||||
api.GET("/availability", availabilityHandler.ListAvailability)
|
api.GET("/availability", availabilityHandler.ListAvailability)
|
||||||
|
|
|
||||||
|
|
@ -513,3 +513,29 @@ func (h *Handler) NotifyLogistics(c *gin.Context) {
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Notificação de logística iniciada com sucesso."})
|
c.JSON(http.StatusOK, gin.H{"message": "Notificação de logística iniciada com sucesso."})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Import godoc
|
||||||
|
// @Summary Import agenda events from Excel/JSON
|
||||||
|
// @Tags agenda
|
||||||
|
// @Router /api/import/agenda [post]
|
||||||
|
func (h *Handler) Import(c *gin.Context) {
|
||||||
|
var req []ImportAgendaRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Dados inválidos: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userIDStr := c.GetString("userID")
|
||||||
|
userID, err := uuid.Parse(userIDStr)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Usuário não autenticado"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.service.ImportAgenda(c.Request.Context(), userID, req); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erro ao importar agenda: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Agenda importada com sucesso"})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -783,3 +783,168 @@ func getFloat64(n pgtype.Numeric) float64 {
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ImportAgendaRequest struct {
|
||||||
|
Fot string `json:"fot"`
|
||||||
|
Data string `json:"data"` // DD/MM/YYYY
|
||||||
|
TipoEvento string `json:"tipo_evento"`
|
||||||
|
Observacoes string `json:"observacoes"` // Obs Evento (Column I)
|
||||||
|
Local string `json:"local"`
|
||||||
|
Endereco string `json:"endereco"`
|
||||||
|
Horario string `json:"horario"`
|
||||||
|
QtdFormandos int32 `json:"qtd_formandos"` // M
|
||||||
|
QtdFotografos int32 `json:"qtd_fotografos"` // N
|
||||||
|
QtdCinegrafistas int32 `json:"qtd_cinegrafistas"` // O - Cinegrafista (Screenshot Col O header "cinegrafista"?)
|
||||||
|
QtdRecepcionistas int32 `json:"qtd_recepcionistas"` // ? Need mapping from header. Screenshot Col P "Estúdio"?
|
||||||
|
// Mapping from Screenshot:
|
||||||
|
// M: Formandos -> qtd_formandos
|
||||||
|
// N: fotografo -> qtd_fotografos
|
||||||
|
// O: cinegrafista -> qtd_cinegrafistas
|
||||||
|
// P: Estudio -> qtd_estudios
|
||||||
|
// Q: Ponto de Foto -> qtd_ponto_foto
|
||||||
|
// R: Ponto de ID -> qtd_ponto_id
|
||||||
|
// S: Ponto -> qtd_ponto_decorado (Assumption)
|
||||||
|
// T: Pontos Led -> qtd_pontos_led
|
||||||
|
// U: Plataforma -> qtd_plataforma_360
|
||||||
|
QtdEstudios int32 `json:"qtd_estudios"`
|
||||||
|
QtdPontoFoto int32 `json:"qtd_ponto_foto"`
|
||||||
|
QtdPontoID int32 `json:"qtd_ponto_id"`
|
||||||
|
QtdPontoDecorado int32 `json:"qtd_ponto_decorado"`
|
||||||
|
QtdPontosLed int32 `json:"qtd_pontos_led"`
|
||||||
|
QtdPlataforma360 int32 `json:"qtd_plataforma_360"`
|
||||||
|
|
||||||
|
FotoFaltante int32 `json:"foto_faltante"`
|
||||||
|
RecepFaltante int32 `json:"recep_faltante"`
|
||||||
|
CineFaltante int32 `json:"cine_faltante"`
|
||||||
|
|
||||||
|
LogisticaObservacoes string `json:"logistica_observacoes"`
|
||||||
|
PreVenda bool `json:"pre_venda"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ImportAgenda(ctx context.Context, userID uuid.UUID, items []ImportAgendaRequest) error {
|
||||||
|
// 1. Pre-load cache if needed or just query. Query is safer (less race conditions).
|
||||||
|
|
||||||
|
for _, item := range items {
|
||||||
|
// Parse Date
|
||||||
|
// Helper to parse DD/MM/YYYY
|
||||||
|
parsedDate, err := time.Parse("02/01/2006", item.Data)
|
||||||
|
if err != nil {
|
||||||
|
// Try standard format or log error?
|
||||||
|
// Fallback
|
||||||
|
parsedDate = time.Now() // Dangerous default, better skip.
|
||||||
|
log.Printf("Erro ao parsear data para FOT %s: %v", item.Fot, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Find FOT
|
||||||
|
// Assume GetCadastroFotByFOT exists in generated.
|
||||||
|
fot, err := s.queries.GetCadastroFotByFOT(ctx, item.Fot)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("FOT %s não encontrado. Pulando evento.", item.Fot)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Find/Create Tipo Evento
|
||||||
|
tipoEventoID, err := s.findOrCreateTipoEvento(ctx, item.TipoEvento)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Erro ao processar Tipo Evento %s: %v", item.TipoEvento, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Upsert Agenda
|
||||||
|
// We will assume that same FOT + Date + Tipo = Same Event
|
||||||
|
// We check if exists
|
||||||
|
|
||||||
|
params := generated.CreateAgendaParams{
|
||||||
|
FotID: pgtype.UUID{Bytes: fot.ID.Bytes, Valid: true},
|
||||||
|
DataEvento: pgtype.Date{Time: parsedDate, Valid: true},
|
||||||
|
TipoEventoID: pgtype.UUID{Bytes: tipoEventoID, Valid: true},
|
||||||
|
ObservacoesEvento: pgtype.Text{String: item.Observacoes, Valid: item.Observacoes != ""},
|
||||||
|
LocalEvento: pgtype.Text{String: item.Local, Valid: item.Local != ""},
|
||||||
|
Endereco: pgtype.Text{String: item.Endereco, Valid: item.Endereco != ""},
|
||||||
|
Horario: pgtype.Text{String: item.Horario, Valid: item.Horario != ""},
|
||||||
|
QtdFormandos: pgtype.Int4{Int32: item.QtdFormandos, Valid: true},
|
||||||
|
QtdFotografos: pgtype.Int4{Int32: item.QtdFotografos, Valid: true},
|
||||||
|
QtdCinegrafistas: pgtype.Int4{Int32: item.QtdCinegrafistas, Valid: true},
|
||||||
|
QtdRecepcionistas: pgtype.Int4{Int32: item.QtdRecepcionistas, Valid: true},
|
||||||
|
QtdEstudios: pgtype.Int4{Int32: item.QtdEstudios, Valid: true},
|
||||||
|
QtdPontoFoto: pgtype.Int4{Int32: item.QtdPontoFoto, Valid: true},
|
||||||
|
QtdPontoID: pgtype.Int4{Int32: item.QtdPontoID, Valid: true},
|
||||||
|
QtdPontoDecorado: pgtype.Int4{Int32: item.QtdPontoDecorado, Valid: true},
|
||||||
|
QtdPontosLed: pgtype.Int4{Int32: item.QtdPontosLed, Valid: true},
|
||||||
|
QtdPlataforma360: pgtype.Int4{Int32: item.QtdPlataforma360, Valid: true},
|
||||||
|
StatusProfissionais: pgtype.Text{String: "OK", Valid: true}, // Recalculated below
|
||||||
|
FotoFaltante: pgtype.Int4{Int32: item.FotoFaltante, Valid: true},
|
||||||
|
RecepFaltante: pgtype.Int4{Int32: item.RecepFaltante, Valid: true},
|
||||||
|
CineFaltante: pgtype.Int4{Int32: item.CineFaltante, Valid: true},
|
||||||
|
LogisticaObservacoes: pgtype.Text{String: item.LogisticaObservacoes, Valid: item.LogisticaObservacoes != ""},
|
||||||
|
PreVenda: pgtype.Bool{Bool: item.PreVenda, Valid: true},
|
||||||
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recalculate status
|
||||||
|
status := s.CalculateStatus(item.FotoFaltante, item.RecepFaltante, item.CineFaltante)
|
||||||
|
params.StatusProfissionais = pgtype.Text{String: status, Valid: true}
|
||||||
|
|
||||||
|
// Attempt Upsert (Check existence)
|
||||||
|
// Ideally we need UpsertAgenda query in sqlc. Since we don't know if it exists, use Check Logic.
|
||||||
|
existing, err := s.queries.GetAgendaByFotDataTipo(ctx, generated.GetAgendaByFotDataTipoParams{
|
||||||
|
FotID: fot.ID,
|
||||||
|
DataEvento: pgtype.Date{Time: parsedDate, Valid: true},
|
||||||
|
TipoEventoID: pgtype.UUID{Bytes: tipoEventoID, Valid: true},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
// Update
|
||||||
|
updateParams := generated.UpdateAgendaParams{
|
||||||
|
ID: existing.ID,
|
||||||
|
FotID: params.FotID,
|
||||||
|
DataEvento: params.DataEvento,
|
||||||
|
TipoEventoID: params.TipoEventoID,
|
||||||
|
ObservacoesEvento: params.ObservacoesEvento,
|
||||||
|
LocalEvento: params.LocalEvento,
|
||||||
|
Endereco: params.Endereco,
|
||||||
|
Horario: params.Horario,
|
||||||
|
QtdFormandos: params.QtdFormandos,
|
||||||
|
QtdFotografos: params.QtdFotografos,
|
||||||
|
QtdCinegrafistas: params.QtdCinegrafistas,
|
||||||
|
QtdRecepcionistas: params.QtdRecepcionistas,
|
||||||
|
QtdEstudios: params.QtdEstudios,
|
||||||
|
QtdPontoFoto: params.QtdPontoFoto,
|
||||||
|
QtdPontoID: params.QtdPontoID,
|
||||||
|
QtdPontoDecorado: params.QtdPontoDecorado,
|
||||||
|
QtdPontosLed: params.QtdPontosLed,
|
||||||
|
QtdPlataforma360: params.QtdPlataforma360,
|
||||||
|
StatusProfissionais: params.StatusProfissionais,
|
||||||
|
FotoFaltante: params.FotoFaltante,
|
||||||
|
RecepFaltante: params.RecepFaltante,
|
||||||
|
CineFaltante: params.CineFaltante,
|
||||||
|
LogisticaObservacoes: params.LogisticaObservacoes,
|
||||||
|
PreVenda: params.PreVenda,
|
||||||
|
}
|
||||||
|
s.queries.UpdateAgenda(ctx, updateParams)
|
||||||
|
} else {
|
||||||
|
// Insert
|
||||||
|
s.queries.CreateAgenda(ctx, params)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) findOrCreateTipoEvento(ctx context.Context, nome string) (uuid.UUID, error) {
|
||||||
|
if nome == "" {
|
||||||
|
nome = "Evento"
|
||||||
|
}
|
||||||
|
// Check if exists
|
||||||
|
te, err := s.queries.GetTipoEventoByNome(ctx, nome)
|
||||||
|
if err == nil {
|
||||||
|
return uuid.UUID(te.ID.Bytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create
|
||||||
|
newTe, err := s.queries.CreateTipoEvento(ctx, nome)
|
||||||
|
if err != nil {
|
||||||
|
return uuid.Nil, err
|
||||||
|
}
|
||||||
|
return uuid.UUID(newTe.ID.Bytes), nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -243,6 +243,55 @@ func (q *Queries) GetAgenda(ctx context.Context, id pgtype.UUID) (Agenda, error)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getAgendaByFotDataTipo = `-- name: GetAgendaByFotDataTipo :one
|
||||||
|
SELECT id, user_id, fot_id, data_evento, tipo_evento_id, observacoes_evento, local_evento, endereco, horario, qtd_formandos, qtd_fotografos, qtd_recepcionistas, qtd_cinegrafistas, qtd_estudios, qtd_ponto_foto, qtd_ponto_id, qtd_ponto_decorado, qtd_pontos_led, qtd_plataforma_360, status_profissionais, foto_faltante, recep_faltante, cine_faltante, logistica_observacoes, pre_venda, criado_em, atualizado_em, status, logistica_notificacao_enviada_em FROM agenda
|
||||||
|
WHERE fot_id = $1 AND data_evento = $2 AND tipo_evento_id = $3
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetAgendaByFotDataTipoParams struct {
|
||||||
|
FotID pgtype.UUID `json:"fot_id"`
|
||||||
|
DataEvento pgtype.Date `json:"data_evento"`
|
||||||
|
TipoEventoID pgtype.UUID `json:"tipo_evento_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetAgendaByFotDataTipo(ctx context.Context, arg GetAgendaByFotDataTipoParams) (Agenda, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getAgendaByFotDataTipo, arg.FotID, arg.DataEvento, arg.TipoEventoID)
|
||||||
|
var i Agenda
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.FotID,
|
||||||
|
&i.DataEvento,
|
||||||
|
&i.TipoEventoID,
|
||||||
|
&i.ObservacoesEvento,
|
||||||
|
&i.LocalEvento,
|
||||||
|
&i.Endereco,
|
||||||
|
&i.Horario,
|
||||||
|
&i.QtdFormandos,
|
||||||
|
&i.QtdFotografos,
|
||||||
|
&i.QtdRecepcionistas,
|
||||||
|
&i.QtdCinegrafistas,
|
||||||
|
&i.QtdEstudios,
|
||||||
|
&i.QtdPontoFoto,
|
||||||
|
&i.QtdPontoID,
|
||||||
|
&i.QtdPontoDecorado,
|
||||||
|
&i.QtdPontosLed,
|
||||||
|
&i.QtdPlataforma360,
|
||||||
|
&i.StatusProfissionais,
|
||||||
|
&i.FotoFaltante,
|
||||||
|
&i.RecepFaltante,
|
||||||
|
&i.CineFaltante,
|
||||||
|
&i.LogisticaObservacoes,
|
||||||
|
&i.PreVenda,
|
||||||
|
&i.CriadoEm,
|
||||||
|
&i.AtualizadoEm,
|
||||||
|
&i.Status,
|
||||||
|
&i.LogisticaNotificacaoEnviadaEm,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const getAgendaProfessionals = `-- name: GetAgendaProfessionals :many
|
const getAgendaProfessionals = `-- name: GetAgendaProfessionals :many
|
||||||
SELECT p.id, p.usuario_id, p.nome, p.funcao_profissional_id, p.endereco, p.cidade, p.uf, p.whatsapp, p.cpf_cnpj_titular, p.banco, p.agencia, 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, f.nome as funcao_nome, u.email
|
SELECT p.id, p.usuario_id, p.nome, p.funcao_profissional_id, p.endereco, p.cidade, p.uf, p.whatsapp, p.cpf_cnpj_titular, p.banco, p.agencia, 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, f.nome as funcao_nome, u.email
|
||||||
FROM cadastro_profissionais p
|
FROM cadastro_profissionais p
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,17 @@ func (q *Queries) GetTipoEventoByID(ctx context.Context, id pgtype.UUID) (TiposE
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getTipoEventoByNome = `-- name: GetTipoEventoByNome :one
|
||||||
|
SELECT id, nome, criado_em FROM tipos_eventos WHERE nome = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetTipoEventoByNome(ctx context.Context, nome string) (TiposEvento, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getTipoEventoByNome, nome)
|
||||||
|
var i TiposEvento
|
||||||
|
err := row.Scan(&i.ID, &i.Nome, &i.CriadoEm)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const listPrecosByEventoID = `-- name: ListPrecosByEventoID :many
|
const listPrecosByEventoID = `-- name: ListPrecosByEventoID :many
|
||||||
SELECT p.id, p.tipo_evento_id, p.funcao_profissional_id, p.valor, p.criado_em, f.nome as funcao_nome
|
SELECT p.id, p.tipo_evento_id, p.funcao_profissional_id, p.valor, p.criado_em, f.nome as funcao_nome
|
||||||
FROM precos_tipos_eventos p
|
FROM precos_tipos_eventos p
|
||||||
|
|
|
||||||
|
|
@ -201,3 +201,8 @@ FROM agenda a
|
||||||
JOIN tipos_eventos te ON a.tipo_evento_id = te.id
|
JOIN tipos_eventos te ON a.tipo_evento_id = te.id
|
||||||
WHERE a.fot_id = $1
|
WHERE a.fot_id = $1
|
||||||
ORDER BY a.data_evento;
|
ORDER BY a.data_evento;
|
||||||
|
|
||||||
|
-- name: GetAgendaByFotDataTipo :one
|
||||||
|
SELECT * FROM agenda
|
||||||
|
WHERE fot_id = $1 AND data_evento = $2 AND tipo_evento_id = $3
|
||||||
|
LIMIT 1;
|
||||||
|
|
@ -39,3 +39,6 @@ JOIN tipos_eventos te ON p.tipo_evento_id = te.id
|
||||||
JOIN funcoes_profissionais f ON p.funcao_profissional_id = f.id
|
JOIN funcoes_profissionais f ON p.funcao_profissional_id = f.id
|
||||||
WHERE te.nome = $1 AND f.nome = $2
|
WHERE te.nome = $1 AND f.nome = $2
|
||||||
LIMIT 1;
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- name: GetTipoEventoByNome :one
|
||||||
|
SELECT * FROM tipos_eventos WHERE nome = $1;
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ import { PrivacyPolicy } from "./pages/PrivacyPolicy";
|
||||||
import { TermsOfUse } from "./pages/TermsOfUse";
|
import { TermsOfUse } from "./pages/TermsOfUse";
|
||||||
import { LGPD } from "./pages/LGPD";
|
import { LGPD } from "./pages/LGPD";
|
||||||
import { AuthProvider, useAuth } from "./contexts/AuthContext";
|
import { AuthProvider, useAuth } from "./contexts/AuthContext";
|
||||||
import { DataProvider } from "./contexts/DataContext";
|
import { DataProvider, useData } from "./contexts/DataContext";
|
||||||
import { UserRole } from "./types";
|
import { UserRole } from "./types";
|
||||||
import { verifyAccessCode } from "./services/apiService";
|
import { verifyAccessCode } from "./services/apiService";
|
||||||
import { Button } from "./components/Button";
|
import { Button } from "./components/Button";
|
||||||
|
|
@ -35,6 +35,7 @@ import { ShieldAlert } from "lucide-react";
|
||||||
import ProfessionalStatement from "./pages/ProfessionalStatement";
|
import ProfessionalStatement from "./pages/ProfessionalStatement";
|
||||||
import { ProfilePage } from "./pages/Profile";
|
import { ProfilePage } from "./pages/Profile";
|
||||||
import { ImportData } from "./pages/ImportData";
|
import { ImportData } from "./pages/ImportData";
|
||||||
|
import { LoadingScreen } from "./components/LoadingScreen";
|
||||||
|
|
||||||
// Componente de acesso negado
|
// Componente de acesso negado
|
||||||
const AccessDenied: React.FC = () => {
|
const AccessDenied: React.FC = () => {
|
||||||
|
|
@ -546,6 +547,12 @@ const Footer: React.FC = () => {
|
||||||
const AppContent: React.FC = () => {
|
const AppContent: React.FC = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const showFooter = location.pathname === "/";
|
const showFooter = location.pathname === "/";
|
||||||
|
const { isLoading: isAuthLoading } = useAuth();
|
||||||
|
const { isLoading: isDataLoading } = useData();
|
||||||
|
|
||||||
|
if (isAuthLoading || isDataLoading) {
|
||||||
|
return <LoadingScreen />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -188,6 +188,21 @@ export const EventTable: React.FC<EventTableProps> = ({
|
||||||
return sorted;
|
return sorted;
|
||||||
}, [events, sortField, sortOrder]);
|
}, [events, sortField, sortOrder]);
|
||||||
|
|
||||||
|
// Pagination State
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const itemsPerPage = 50;
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(sortedEvents.length / itemsPerPage);
|
||||||
|
const paginatedEvents = useMemo(() => {
|
||||||
|
const start = (currentPage - 1) * itemsPerPage;
|
||||||
|
return sortedEvents.slice(start, start + itemsPerPage);
|
||||||
|
}, [sortedEvents, currentPage]);
|
||||||
|
|
||||||
|
// Reset page when filters/sort change (implicitly when sortedEvents length changes drastically, though here sortedEvents updates)
|
||||||
|
React.useEffect(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, [events.length, sortField, sortOrder]); // Reset if data source changes significantly
|
||||||
|
|
||||||
const getSortIcon = (field: SortField) => {
|
const getSortIcon = (field: SortField) => {
|
||||||
if (sortField !== field) {
|
if (sortField !== field) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -224,8 +239,32 @@ export const EventTable: React.FC<EventTableProps> = ({
|
||||||
return statusLabels[status] || status;
|
return statusLabels[status] || status;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Scroll Sync Logic
|
||||||
|
const tableContainerRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const topScrollRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const [tableScrollWidth, setTableScrollWidth] = useState(0);
|
||||||
|
|
||||||
|
// Sync scroll width
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (tableContainerRef.current) {
|
||||||
|
setTableScrollWidth(tableContainerRef.current.scrollWidth);
|
||||||
|
}
|
||||||
|
}, [events, sortedEvents, isManagingTeam]); // Dependencies that change table width
|
||||||
|
|
||||||
|
const handleTopScroll = () => {
|
||||||
|
if (topScrollRef.current && tableContainerRef.current) {
|
||||||
|
tableContainerRef.current.scrollLeft = topScrollRef.current.scrollLeft;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTableScroll = () => {
|
||||||
|
if (topScrollRef.current && tableContainerRef.current) {
|
||||||
|
topScrollRef.current.scrollLeft = tableContainerRef.current.scrollLeft;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden shadow-sm">
|
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden shadow-sm flex flex-col">
|
||||||
{/* Mobile Card View */}
|
{/* Mobile Card View */}
|
||||||
<div className="md:hidden divide-y divide-gray-100">
|
<div className="md:hidden divide-y divide-gray-100">
|
||||||
{sortedEvents.map((event) => {
|
{sortedEvents.map((event) => {
|
||||||
|
|
@ -329,7 +368,19 @@ export const EventTable: React.FC<EventTableProps> = ({
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden md:block overflow-x-auto">
|
<div className="hidden md:block overflow-x-auto border-b border-gray-200 bg-gray-50 mb-1"
|
||||||
|
ref={topScrollRef}
|
||||||
|
onScroll={handleTopScroll}
|
||||||
|
style={{ minHeight: '12px' }}
|
||||||
|
>
|
||||||
|
<div style={{ width: tableScrollWidth > 0 ? tableScrollWidth : '100%', height: '1px' }}></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="hidden md:block overflow-x-auto"
|
||||||
|
ref={tableContainerRef}
|
||||||
|
onScroll={handleTableScroll}
|
||||||
|
>
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="bg-gray-50 border-b border-gray-200">
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -454,7 +505,7 @@ export const EventTable: React.FC<EventTableProps> = ({
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100">
|
<tbody className="divide-y divide-gray-100">
|
||||||
{sortedEvents.map((event) => {
|
{paginatedEvents.map((event) => {
|
||||||
// Logic to find photographer assignment status
|
// Logic to find photographer assignment status
|
||||||
let photographerAssignment = null;
|
let photographerAssignment = null;
|
||||||
if (isPhotographer && currentProfessionalId && event.assignments) {
|
if (isPhotographer && currentProfessionalId && event.assignments) {
|
||||||
|
|
@ -675,6 +726,79 @@ export const EventTable: React.FC<EventTableProps> = ({
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination Controls */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 bg-white border-t border-gray-200 sm:px-6">
|
||||||
|
<div className="flex justify-between w-full sm:hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Anterior
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className="relative ml-3 inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Próxima
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-700">
|
||||||
|
Mostrando página <span className="font-medium">{currentPage}</span> de{" "}
|
||||||
|
<span className="font-medium">{totalPages}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<nav className="inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<span className="sr-only">Anterior</span>
|
||||||
|
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
<path fillRule="evenodd" d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/* Simplified Pagination Numbers */}
|
||||||
|
{[...Array(Math.min(5, totalPages))].map((_, idx) => {
|
||||||
|
// Logic to show pages around current
|
||||||
|
let pageNum = currentPage;
|
||||||
|
if (totalPages <= 5) pageNum = idx + 1;
|
||||||
|
else if (currentPage <= 3) pageNum = idx + 1;
|
||||||
|
else if (currentPage >= totalPages - 2) pageNum = totalPages - 4 + idx;
|
||||||
|
else pageNum = currentPage - 2 + idx;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={pageNum}
|
||||||
|
onClick={() => setCurrentPage(pageNum)}
|
||||||
|
className={`relative inline-flex items-center px-4 py-2 text-sm font-semibold ${currentPage === pageNum ? 'bg-brand-gold text-white focus:z-20 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-gold' : 'text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0'}`}
|
||||||
|
>
|
||||||
|
{pageNum}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className="relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<span className="sr-only">Próxima</span>
|
||||||
|
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
<path fillRule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{sortedEvents.length === 0 && (
|
{sortedEvents.length === 0 && (
|
||||||
<div className="text-center py-12 text-gray-500">
|
<div className="text-center py-12 text-gray-500">
|
||||||
<p>Nenhum evento encontrado.</p>
|
<p>Nenhum evento encontrado.</p>
|
||||||
|
|
|
||||||
21
frontend/components/LoadingScreen.tsx
Normal file
21
frontend/components/LoadingScreen.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export const LoadingScreen: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-white z-50 flex flex-col items-center justify-center">
|
||||||
|
<div className="relative w-20 h-20">
|
||||||
|
{/* Animated Rings */}
|
||||||
|
<div className="absolute inset-0 border-4 border-gray-100 rounded-full"></div>
|
||||||
|
<div className="absolute inset-0 border-4 border-brand-gold rounded-full border-t-transparent animate-spin"></div>
|
||||||
|
|
||||||
|
{/* Logo or Icon in Center */}
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="w-8 h-8 bg-brand-black rounded-sm transform rotate-45 flex items-center justify-center">
|
||||||
|
<div className="w-3 h-3 bg-white rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-gray-500 text-sm font-medium animate-pulse">Carregando...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -48,6 +48,7 @@ interface AuthContextType {
|
||||||
register: (data: { nome: string; email: string; senha: string; telefone: string; role: string; empresaId?: string; tipo_profissional?: string }) => Promise<{ success: boolean; userId?: string; token?: string }>;
|
register: (data: { nome: string; email: string; senha: string; telefone: string; role: string; empresaId?: string; tipo_profissional?: string }) => Promise<{ success: boolean; userId?: string; token?: string }>;
|
||||||
availableUsers: User[]; // Helper for the login screen demo
|
availableUsers: User[]; // Helper for the login screen demo
|
||||||
token: string | null;
|
token: string | null;
|
||||||
|
isLoading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
@ -56,9 +57,17 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [token, setToken] = useState<string | null>(localStorage.getItem("token"));
|
const [token, setToken] = useState<string | null>(localStorage.getItem("token"));
|
||||||
|
|
||||||
|
// Initial loading state depends on whether we have a token to verify
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(!!localStorage.getItem("token"));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const restoreSession = async () => {
|
const restoreSession = async () => {
|
||||||
if (!token) return;
|
if (!token) {
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8080";
|
const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8080";
|
||||||
|
|
@ -94,11 +103,15 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Session restore error:", err);
|
console.error("Session restore error:", err);
|
||||||
logout();
|
logout();
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!user && token) {
|
if (token) {
|
||||||
restoreSession();
|
restoreSession();
|
||||||
|
} else {
|
||||||
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [token]); // removed 'user' from dependency to avoid loop if user is set, though !user check handles it. safer to just depend on token mount.
|
}, [token]); // removed 'user' from dependency to avoid loop if user is set, though !user check handles it. safer to just depend on token mount.
|
||||||
|
|
||||||
|
|
@ -263,7 +276,7 @@ const login = async (email: string, password?: string) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, token, login, logout, register, availableUsers: MOCK_USERS }}>
|
<AuthContext.Provider value={{ user, token, login, logout, register, availableUsers: MOCK_USERS, isLoading }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -610,6 +610,7 @@ interface DataContextType {
|
||||||
respondToAssignment: (eventId: string, status: string, reason?: string) => Promise<void>;
|
respondToAssignment: (eventId: string, status: string, reason?: string) => Promise<void>;
|
||||||
updateEventDetails: (id: string, data: any) => Promise<void>;
|
updateEventDetails: (id: string, data: any) => Promise<void>;
|
||||||
functions: { id: string; nome: string }[];
|
functions: { id: string; nome: string }[];
|
||||||
|
isLoading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DataContext = createContext<DataContextType | undefined>(undefined);
|
const DataContext = createContext<DataContextType | undefined>(undefined);
|
||||||
|
|
@ -624,6 +625,8 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({
|
||||||
useState<Institution[]>(INITIAL_INSTITUTIONS);
|
useState<Institution[]>(INITIAL_INSTITUTIONS);
|
||||||
const [courses, setCourses] = useState<Course[]>(INITIAL_COURSES);
|
const [courses, setCourses] = useState<Course[]>(INITIAL_COURSES);
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
|
|
||||||
const [pendingUsers, setPendingUsers] = useState<User[]>([]);
|
const [pendingUsers, setPendingUsers] = useState<User[]>([]);
|
||||||
const [professionals, setProfessionals] = useState<Professional[]>([]);
|
const [professionals, setProfessionals] = useState<Professional[]>([]);
|
||||||
const [functions, setFunctions] = useState<{ id: string; nome: string }[]>([]);
|
const [functions, setFunctions] = useState<{ id: string; nome: string }[]>([]);
|
||||||
|
|
@ -635,6 +638,7 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({
|
||||||
const visibleToken = token || localStorage.getItem("token");
|
const visibleToken = token || localStorage.getItem("token");
|
||||||
|
|
||||||
if (visibleToken) {
|
if (visibleToken) {
|
||||||
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
// Import dynamic to avoid circular dependency if any, or just use imported service
|
// Import dynamic to avoid circular dependency if any, or just use imported service
|
||||||
const { getAgendas, getFunctions } = await import("../services/apiService");
|
const { getAgendas, getFunctions } = await import("../services/apiService");
|
||||||
|
|
@ -733,6 +737,8 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch events", error);
|
console.error("Failed to fetch events", error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -1091,6 +1097,22 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({
|
||||||
institutions,
|
institutions,
|
||||||
courses,
|
courses,
|
||||||
pendingUsers,
|
pendingUsers,
|
||||||
|
addEvent,
|
||||||
|
updateEventStatus,
|
||||||
|
assignPhotographer,
|
||||||
|
getEventsByRole,
|
||||||
|
addInstitution,
|
||||||
|
updateInstitution,
|
||||||
|
getInstitutionsByUserId,
|
||||||
|
getInstitutionById,
|
||||||
|
addCourse,
|
||||||
|
updateCourse,
|
||||||
|
getCoursesByInstitutionId,
|
||||||
|
getActiveCoursesByInstitutionId,
|
||||||
|
getCourseById,
|
||||||
|
registerPendingUser,
|
||||||
|
approveUser,
|
||||||
|
rejectUser,
|
||||||
professionals,
|
professionals,
|
||||||
respondToAssignment: async (eventId, status, reason) => {
|
respondToAssignment: async (eventId, status, reason) => {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
|
|
@ -1162,23 +1184,8 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({
|
||||||
}
|
}
|
||||||
} catch (e) { console.error("Refresh failed", e); }
|
} catch (e) { console.error("Refresh failed", e); }
|
||||||
},
|
},
|
||||||
addEvent,
|
|
||||||
updateEventStatus,
|
|
||||||
functions,
|
functions,
|
||||||
assignPhotographer,
|
isLoading,
|
||||||
getEventsByRole,
|
|
||||||
addInstitution,
|
|
||||||
updateInstitution,
|
|
||||||
getInstitutionsByUserId,
|
|
||||||
getInstitutionById,
|
|
||||||
addCourse,
|
|
||||||
updateCourse,
|
|
||||||
getCoursesByInstitutionId,
|
|
||||||
getActiveCoursesByInstitutionId,
|
|
||||||
getCourseById,
|
|
||||||
registerPendingUser,
|
|
||||||
approveUser,
|
|
||||||
rejectUser,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,13 @@ import React, { useState } from 'react';
|
||||||
import * as XLSX from 'xlsx';
|
import * as XLSX from 'xlsx';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { Button } from '../components/Button';
|
import { Button } from '../components/Button';
|
||||||
import { Upload, FileText, CheckCircle, AlertTriangle } from 'lucide-react';
|
import { Upload, FileText, CheckCircle, AlertTriangle, Calendar, Database } from 'lucide-react';
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8080";
|
const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8080";
|
||||||
|
|
||||||
interface ImportInput {
|
type ImportType = 'fot' | 'agenda';
|
||||||
|
|
||||||
|
interface ImportFotInput {
|
||||||
fot: string;
|
fot: string;
|
||||||
empresa_nome: string;
|
empresa_nome: string;
|
||||||
curso_nome: string;
|
curso_nome: string;
|
||||||
|
|
@ -19,14 +21,51 @@ interface ImportInput {
|
||||||
pre_venda: boolean;
|
pre_venda: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ImportAgendaInput {
|
||||||
|
fot: string;
|
||||||
|
data: string;
|
||||||
|
tipo_evento: string;
|
||||||
|
observacoes: string;
|
||||||
|
local: string;
|
||||||
|
endereco: string;
|
||||||
|
horario: string;
|
||||||
|
qtd_formandos: number;
|
||||||
|
qtd_fotografos: number;
|
||||||
|
qtd_cinegrafistas: number;
|
||||||
|
qtd_recepcionistas: number;
|
||||||
|
qtd_estudios: number;
|
||||||
|
qtd_ponto_foto: number;
|
||||||
|
qtd_ponto_id: number;
|
||||||
|
qtd_ponto_decorado: number;
|
||||||
|
qtd_pontos_led: number;
|
||||||
|
qtd_plataforma_360: number;
|
||||||
|
foto_faltante: number;
|
||||||
|
recep_faltante: number;
|
||||||
|
cine_faltante: number;
|
||||||
|
logistica_observacoes: string;
|
||||||
|
pre_venda: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export const ImportData: React.FC = () => {
|
export const ImportData: React.FC = () => {
|
||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
const [data, setData] = useState<ImportInput[]>([]);
|
const [activeTab, setActiveTab] = useState<ImportType>('fot');
|
||||||
const [preview, setPreview] = useState<ImportInput[]>([]);
|
|
||||||
|
// Generic data state (can be Fot or Agenda)
|
||||||
|
const [data, setData] = useState<any[]>([]);
|
||||||
const [filename, setFilename] = useState<string>("");
|
const [filename, setFilename] = useState<string>("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [result, setResult] = useState<{ success: number; errors: string[] } | null>(null);
|
const [result, setResult] = useState<{ success: number; errors: string[] } | null>(null);
|
||||||
|
|
||||||
|
// Clear data when switching tabs
|
||||||
|
const handleTabChange = (tab: ImportType) => {
|
||||||
|
if (tab !== activeTab) {
|
||||||
|
setActiveTab(tab);
|
||||||
|
setData([]);
|
||||||
|
setFilename("");
|
||||||
|
setResult(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
@ -40,44 +79,113 @@ export const ImportData: React.FC = () => {
|
||||||
const ws = wb.Sheets[wsname];
|
const ws = wb.Sheets[wsname];
|
||||||
const jsonData = XLSX.utils.sheet_to_json(ws, { header: 1 }) as any[][];
|
const jsonData = XLSX.utils.sheet_to_json(ws, { header: 1 }) as any[][];
|
||||||
|
|
||||||
// Assuming header is row 0
|
let mappedData: any[] = [];
|
||||||
// Map columns based on index (A=0, B=1, ... J=9) based on screenshot
|
|
||||||
const mappedData: ImportInput[] = [];
|
|
||||||
// Start from row 1 (skip header)
|
// Start from row 1 (skip header)
|
||||||
for (let i = 1; i < jsonData.length; i++) {
|
for (let i = 1; i < jsonData.length; i++) {
|
||||||
const row = jsonData[i];
|
const row = jsonData[i];
|
||||||
if (!row || row.length === 0) continue;
|
if (!row || row.length === 0) continue;
|
||||||
|
|
||||||
// Helper to get string safely
|
// Helper to get string
|
||||||
const getStr = (idx: number) => row[idx] ? String(row[idx]).trim() : "";
|
const getStr = (idx: number) => row[idx] ? String(row[idx]).trim() : "";
|
||||||
|
// Helper to get int
|
||||||
|
const getInt = (idx: number) => {
|
||||||
|
const val = row[idx];
|
||||||
|
if (!val) return 0;
|
||||||
|
if (typeof val === 'number') return Math.floor(val);
|
||||||
|
const parsed = parseInt(String(val).replace(/\D/g, ''), 10);
|
||||||
|
return isNaN(parsed) ? 0 : parsed;
|
||||||
|
};
|
||||||
|
|
||||||
// Skip empty FOT lines?
|
|
||||||
const fot = getStr(0);
|
const fot = getStr(0);
|
||||||
if (!fot) continue;
|
if (!fot) continue;
|
||||||
|
|
||||||
// Parse Gastos (Remove 'R$', replace ',' with '.')
|
if (activeTab === 'fot') {
|
||||||
let gastosStr = getStr(8); // Col I
|
// Parse Gastos
|
||||||
// Remove R$, spaces, thousands separator (.) and replace decimal (,) with .
|
let gastosStr = getStr(8);
|
||||||
// Example: "R$ 2.500,00" -> "2500.00"
|
gastosStr = gastosStr.replace(/[R$\s.]/g, '').replace(',', '.');
|
||||||
gastosStr = gastosStr.replace(/[R$\s.]/g, '').replace(',', '.');
|
const gastos = parseFloat(gastosStr) || 0;
|
||||||
const gastos = parseFloat(gastosStr) || 0;
|
|
||||||
|
|
||||||
const importItem: ImportInput = {
|
const item: ImportFotInput = {
|
||||||
fot: fot,
|
fot: fot,
|
||||||
empresa_nome: getStr(1), // Col B
|
empresa_nome: getStr(1),
|
||||||
curso_nome: getStr(2), // Col C
|
curso_nome: getStr(2),
|
||||||
observacoes: getStr(3), // Col D
|
observacoes: getStr(3),
|
||||||
instituicao: getStr(4), // Col E
|
instituicao: getStr(4),
|
||||||
ano_formatura_label: getStr(5), // Col F
|
ano_formatura_label: getStr(5),
|
||||||
cidade: getStr(6), // Col G
|
cidade: getStr(6),
|
||||||
estado: getStr(7), // Col H
|
estado: getStr(7),
|
||||||
gastos_captacao: gastos, // Col I
|
gastos_captacao: gastos,
|
||||||
pre_venda: getStr(9).toLowerCase().includes('sim'), // Col J
|
pre_venda: getStr(9).toLowerCase().includes('sim'),
|
||||||
};
|
};
|
||||||
mappedData.push(importItem);
|
mappedData.push(item);
|
||||||
|
|
||||||
|
} else if (activeTab === 'agenda') {
|
||||||
|
// Agenda Parsing
|
||||||
|
// A: FOT (0)
|
||||||
|
// B: Data (1) - Excel often stores dates as numbers. Need formatting helper?
|
||||||
|
// If cell.t is 'n', use XLSX.SSF? Or XLSX.utils.sheet_to_json with raw: false might help but header:1 is safer.
|
||||||
|
// If using header:1, date might be number (days since 1900) or string.
|
||||||
|
// Let's assume text for simplicity or basic number check.
|
||||||
|
let dateStr = getStr(1);
|
||||||
|
if (typeof row[1] === 'number') {
|
||||||
|
// Approximate JS Date
|
||||||
|
const dateObj = new Date(Math.round((row[1] - 25569)*86400*1000));
|
||||||
|
// Convert to DD/MM/YYYY
|
||||||
|
dateStr = dateObj.toLocaleDateString('pt-BR');
|
||||||
|
}
|
||||||
|
|
||||||
|
const item: ImportAgendaInput = {
|
||||||
|
fot: fot,
|
||||||
|
data: dateStr,
|
||||||
|
tipo_evento: getStr(7), // H
|
||||||
|
observacoes: getStr(8), // I
|
||||||
|
local: getStr(9), // J
|
||||||
|
endereco: getStr(10), // K
|
||||||
|
horario: getStr(11), // L
|
||||||
|
qtd_formandos: getInt(12), // M
|
||||||
|
qtd_fotografos: getInt(13), // N
|
||||||
|
qtd_cinegrafistas: getInt(14), // O
|
||||||
|
qtd_estudios: getInt(15), // P (Assumed Estufio?) Screenshot check: Col P header unreadable? "estúdio"?
|
||||||
|
qtd_recepcionistas: getInt(22) > 0 ? getInt(22) : 0, // Wait, where is recep?
|
||||||
|
// Look at screenshot headers:
|
||||||
|
// M: Formandos
|
||||||
|
// N: fotografo
|
||||||
|
// O: cinegrafista / cinegrafista
|
||||||
|
// P: estúdio
|
||||||
|
// Q: ponto de foto
|
||||||
|
// R: ponto de ID
|
||||||
|
// S: Ponto
|
||||||
|
// T: pontos Led
|
||||||
|
// U: plataforma 360
|
||||||
|
// W: Profissionais Ok?
|
||||||
|
// Recp missing? Maybe column V?
|
||||||
|
// Or maybe Recepcionistas are implied in "Profissionais"?
|
||||||
|
// Let's assume 0 for now unless we find columns.
|
||||||
|
// Wait, screenshot shows icons.
|
||||||
|
// X: Camera icon (Falta Foto)
|
||||||
|
// Y: Woman icon (Falta Recep) -> so Recep info exists?
|
||||||
|
// Maybe "Recepcionistas" is column ?
|
||||||
|
// Let's stick to what we see.
|
||||||
|
|
||||||
|
qtd_ponto_foto: getInt(16), // Q
|
||||||
|
qtd_ponto_id: getInt(17), // R
|
||||||
|
qtd_ponto_decorado: getInt(18), // S
|
||||||
|
qtd_pontos_led: getInt(19), // T
|
||||||
|
qtd_plataforma_360: getInt(20), // U
|
||||||
|
|
||||||
|
// Falta
|
||||||
|
foto_faltante: parseInt(row[23]) || 0, // X (Allow negative)
|
||||||
|
recep_faltante: parseInt(row[24]) || 0, // Y
|
||||||
|
cine_faltante: parseInt(row[25]) || 0, // Z
|
||||||
|
|
||||||
|
logistica_observacoes: getStr(26), // AA
|
||||||
|
pre_venda: getStr(27).toLowerCase().includes('sim'), // AB
|
||||||
|
};
|
||||||
|
mappedData.push(item);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setData(mappedData);
|
setData(mappedData);
|
||||||
setPreview(mappedData.slice(0, 5));
|
|
||||||
setResult(null);
|
setResult(null);
|
||||||
};
|
};
|
||||||
reader.readAsBinaryString(file);
|
reader.readAsBinaryString(file);
|
||||||
|
|
@ -87,7 +195,8 @@ export const ImportData: React.FC = () => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}/api/import/fot`, {
|
const endpoint = activeTab === 'fot' ? '/api/import/fot' : '/api/import/agenda';
|
||||||
|
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
|
@ -101,11 +210,18 @@ export const ImportData: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const resData = await response.json();
|
const resData = await response.json();
|
||||||
setResult({
|
// Agenda response might be different?
|
||||||
success: resData.SuccessCount,
|
// Fot response: {SuccessCount, Errors}.
|
||||||
errors: resData.Errors || []
|
// Agenda response: {message}. I should unifiy or handle both.
|
||||||
});
|
|
||||||
// Clear data on success? Maybe keep for review.
|
if (resData.message) {
|
||||||
|
setResult({ success: data.length, errors: [] }); // Assume all success if message only
|
||||||
|
} else {
|
||||||
|
setResult({
|
||||||
|
success: resData.SuccessCount,
|
||||||
|
errors: resData.Errors || []
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Import error:", error);
|
console.error("Import error:", error);
|
||||||
alert("Erro ao importar dados. Verifique o console.");
|
alert("Erro ao importar dados. Verifique o console.");
|
||||||
|
|
@ -126,20 +242,13 @@ export const ImportData: React.FC = () => {
|
||||||
setStartX(e.pageX - tableContainerRef.current.offsetLeft);
|
setStartX(e.pageX - tableContainerRef.current.offsetLeft);
|
||||||
setScrollLeft(tableContainerRef.current.scrollLeft);
|
setScrollLeft(tableContainerRef.current.scrollLeft);
|
||||||
};
|
};
|
||||||
|
const handleMouseLeave = () => { setIsDragging(false); };
|
||||||
const handleMouseLeave = () => {
|
const handleMouseUp = () => { setIsDragging(false); };
|
||||||
setIsDragging(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
|
||||||
setIsDragging(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseMove = (e: React.MouseEvent) => {
|
const handleMouseMove = (e: React.MouseEvent) => {
|
||||||
if (!isDragging || !tableContainerRef.current) return;
|
if (!isDragging || !tableContainerRef.current) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const x = e.pageX - tableContainerRef.current.offsetLeft;
|
const x = e.pageX - tableContainerRef.current.offsetLeft;
|
||||||
const walk = (x - startX) * 2; // Scroll-fast
|
const walk = (x - startX) * 2;
|
||||||
tableContainerRef.current.scrollLeft = scrollLeft - walk;
|
tableContainerRef.current.scrollLeft = scrollLeft - walk;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -147,13 +256,52 @@ export const ImportData: React.FC = () => {
|
||||||
<div className="min-h-screen bg-gray-50 pt-20 pb-12 px-4 sm:px-6 lg:px-8">
|
<div className="min-h-screen bg-gray-50 pt-20 pb-12 px-4 sm:px-6 lg:px-8">
|
||||||
<div className="max-w-6xl mx-auto space-y-8">
|
<div className="max-w-6xl mx-auto space-y-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Importação de Dados (FOT)</h1>
|
<h1 className="text-3xl font-bold text-gray-900">Importação de Dados</h1>
|
||||||
<p className="mt-2 text-gray-600">
|
<p className="mt-2 text-gray-600">
|
||||||
Importe dados da planilha Excel para o sistema. Certifique-se que as colunas seguem o padrão.
|
Carregue planilhas do Excel para alimentar o sistema.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2 text-sm text-gray-500 bg-blue-50 p-4 rounded-md">
|
</div>
|
||||||
<strong>Colunas Esperadas (A-J):</strong> FOT, Empresa, Curso, Observações, Instituição, Ano Formatura, Cidade, Estado, Gastos Captação, Pré Venda.
|
|
||||||
</div>
|
{/* Tabs */}
|
||||||
|
<div className="border-b border-gray-200">
|
||||||
|
<nav className="-mb-px flex space-x-8" aria-label="Tabs">
|
||||||
|
<button
|
||||||
|
onClick={() => handleTabChange('fot')}
|
||||||
|
className={`${
|
||||||
|
activeTab === 'fot'
|
||||||
|
? 'border-blue-500 text-blue-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||||
|
} whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm flex items-center gap-2`}
|
||||||
|
>
|
||||||
|
<Database className="w-4 h-4" />
|
||||||
|
Cadastro FOT
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleTabChange('agenda')}
|
||||||
|
className={`${
|
||||||
|
activeTab === 'agenda'
|
||||||
|
? 'border-blue-500 text-blue-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||||
|
} whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm flex items-center gap-2`}
|
||||||
|
>
|
||||||
|
<Calendar className="w-4 h-4" />
|
||||||
|
Agenda de Eventos
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Box */}
|
||||||
|
<div className="mt-2 text-sm text-gray-500 bg-blue-50 p-4 rounded-md">
|
||||||
|
{activeTab === 'fot' ? (
|
||||||
|
<strong>Colunas Esperadas (A-J):</strong>
|
||||||
|
) : (
|
||||||
|
<strong>Colunas Esperadas (A-AB):</strong>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'fot'
|
||||||
|
? "FOT, Empresa, Curso, Observações, Instituição, Ano Formatura, Cidade, Estado, Gastos Captação, Pré Venda."
|
||||||
|
: "FOT, Data, ..., Tipo Evento, Obs, Local, Endereço, Horário, Qtds (Formandos, Foto, Cine...), Faltantes, Logística."
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white p-6 rounded-lg shadow space-y-4">
|
<div className="bg-white p-6 rounded-lg shadow space-y-4">
|
||||||
|
|
@ -179,7 +327,7 @@ export const ImportData: React.FC = () => {
|
||||||
<div className="bg-white rounded-lg shadow overflow-hidden flex flex-col">
|
<div className="bg-white rounded-lg shadow overflow-hidden flex flex-col">
|
||||||
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50 flex justify-between items-center flex-wrap gap-2">
|
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50 flex justify-between items-center flex-wrap gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="text-lg font-medium text-gray-900">Pré-visualização</h3>
|
<h3 className="text-lg font-medium text-gray-900">Pré-visualização ({activeTab === 'fot' ? 'FOT' : 'Agenda'})</h3>
|
||||||
<span className="text-sm font-semibold bg-gray-200 px-2 py-1 rounded-full text-gray-700">Total: {data.length}</span>
|
<span className="text-sm font-semibold bg-gray-200 px-2 py-1 rounded-full text-gray-700">Total: {data.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleImport} isLoading={isLoading}>
|
<Button onClick={handleImport} isLoading={isLoading}>
|
||||||
|
|
@ -187,7 +335,6 @@ export const ImportData: React.FC = () => {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scrollable Container with Drag Support */}
|
|
||||||
<div
|
<div
|
||||||
ref={tableContainerRef}
|
ref={tableContainerRef}
|
||||||
className={`overflow-auto max-h-[600px] border-b border-gray-200 ${isDragging ? 'cursor-grabbing' : 'cursor-grab'}`}
|
className={`overflow-auto max-h-[600px] border-b border-gray-200 ${isDragging ? 'cursor-grabbing' : 'cursor-grab'}`}
|
||||||
|
|
@ -199,27 +346,51 @@ export const ImportData: React.FC = () => {
|
||||||
<table className="min-w-full divide-y divide-gray-200 relative">
|
<table className="min-w-full divide-y divide-gray-200 relative">
|
||||||
<thead className="bg-gray-50 sticky top-0 z-10 shadow-sm">
|
<thead className="bg-gray-50 sticky top-0 z-10 shadow-sm">
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">FOT</th>
|
{activeTab === 'fot' ? (
|
||||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Empresa</th>
|
<>
|
||||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Curso</th>
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">FOT</th>
|
||||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Instituição</th>
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Empresa</th>
|
||||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Ano</th>
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Curso</th>
|
||||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Cidade/UF</th>
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Instituição</th>
|
||||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Gastos</th>
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Ano</th>
|
||||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Pré Venda</th>
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Gastos</th>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">FOT</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Data</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Evento</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Local</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Horário</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Formandos</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Logística</th>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
{data.map((row, idx) => (
|
{data.map((row, idx) => (
|
||||||
<tr key={idx} className="hover:bg-gray-50 transition-colors">
|
<tr key={idx} className="hover:bg-gray-50 transition-colors">
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{row.fot}</td>
|
{activeTab === 'fot' ? (
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{row.empresa_nome}</td>
|
<>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{row.curso_nome}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{row.fot}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{row.instituicao}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{row.empresa_nome}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{row.ano_formatura_label}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{row.curso_nome}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{row.cidade}/{row.estado}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{row.instituicao}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">R$ {row.gastos_captacao.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{row.ano_formatura_label}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{row.pre_venda ? 'Sim' : 'Não'}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">R$ {row.gastos_captacao?.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}</td>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{row.fot}</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{row.data}</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{row.tipo_evento}</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{row.local}</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{row.horario}</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{row.qtd_formandos}</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 truncate max-w-xs">{row.logistica_observacoes}</td>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
@ -255,7 +426,7 @@ export const ImportData: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<Button variant="outline" onClick={() => { setData([]); setPreview([]); setResult(null); setFilename(""); }}>
|
<Button variant="outline" onClick={() => { setData([]); setResult(null); setFilename(""); }}>
|
||||||
Importar Novo Arquivo
|
Importar Novo Arquivo
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue