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/status", auth.RequireWriteAccess(), agendaHandler.UpdateStatus)
|
||||
api.POST("/agenda/:id/notify-logistics", auth.RequireWriteAccess(), agendaHandler.NotifyLogistics)
|
||||
api.POST("/import/agenda", auth.RequireWriteAccess(), agendaHandler.Import)
|
||||
|
||||
api.POST("/availability", availabilityHandler.SetAvailability)
|
||||
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."})
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -121,6 +121,17 @@ func (q *Queries) GetTipoEventoByID(ctx context.Context, id pgtype.UUID) (TiposE
|
|||
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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -200,4 +200,9 @@ SELECT
|
|||
FROM agenda a
|
||||
JOIN tipos_eventos te ON a.tipo_evento_id = te.id
|
||||
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
|
||||
WHERE te.nome = $1 AND f.nome = $2
|
||||
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 { LGPD } from "./pages/LGPD";
|
||||
import { AuthProvider, useAuth } from "./contexts/AuthContext";
|
||||
import { DataProvider } from "./contexts/DataContext";
|
||||
import { DataProvider, useData } from "./contexts/DataContext";
|
||||
import { UserRole } from "./types";
|
||||
import { verifyAccessCode } from "./services/apiService";
|
||||
import { Button } from "./components/Button";
|
||||
|
|
@ -35,6 +35,7 @@ import { ShieldAlert } from "lucide-react";
|
|||
import ProfessionalStatement from "./pages/ProfessionalStatement";
|
||||
import { ProfilePage } from "./pages/Profile";
|
||||
import { ImportData } from "./pages/ImportData";
|
||||
import { LoadingScreen } from "./components/LoadingScreen";
|
||||
|
||||
// Componente de acesso negado
|
||||
const AccessDenied: React.FC = () => {
|
||||
|
|
@ -546,6 +547,12 @@ const Footer: React.FC = () => {
|
|||
const AppContent: React.FC = () => {
|
||||
const location = useLocation();
|
||||
const showFooter = location.pathname === "/";
|
||||
const { isLoading: isAuthLoading } = useAuth();
|
||||
const { isLoading: isDataLoading } = useData();
|
||||
|
||||
if (isAuthLoading || isDataLoading) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -188,6 +188,21 @@ export const EventTable: React.FC<EventTableProps> = ({
|
|||
return sorted;
|
||||
}, [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) => {
|
||||
if (sortField !== field) {
|
||||
return (
|
||||
|
|
@ -224,8 +239,32 @@ export const EventTable: React.FC<EventTableProps> = ({
|
|||
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 (
|
||||
<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 */}
|
||||
<div className="md:hidden divide-y divide-gray-100">
|
||||
{sortedEvents.map((event) => {
|
||||
|
|
@ -329,7 +368,19 @@ export const EventTable: React.FC<EventTableProps> = ({
|
|||
})}
|
||||
</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">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
|
|
@ -454,7 +505,7 @@ export const EventTable: React.FC<EventTableProps> = ({
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{sortedEvents.map((event) => {
|
||||
{paginatedEvents.map((event) => {
|
||||
// Logic to find photographer assignment status
|
||||
let photographerAssignment = null;
|
||||
if (isPhotographer && currentProfessionalId && event.assignments) {
|
||||
|
|
@ -675,6 +726,79 @@ export const EventTable: React.FC<EventTableProps> = ({
|
|||
</table>
|
||||
</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 && (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<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 }>;
|
||||
availableUsers: User[]; // Helper for the login screen demo
|
||||
token: string | null;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
|
@ -55,10 +56,18 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
|||
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
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(() => {
|
||||
const restoreSession = async () => {
|
||||
if (!token) return;
|
||||
if (!token) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
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) {
|
||||
console.error("Session restore error:", err);
|
||||
logout();
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!user && token) {
|
||||
if (token) {
|
||||
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.
|
||||
|
||||
|
|
@ -263,7 +276,7 @@ const login = async (email: string, password?: string) => {
|
|||
};
|
||||
|
||||
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}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -610,6 +610,7 @@ interface DataContextType {
|
|||
respondToAssignment: (eventId: string, status: string, reason?: string) => Promise<void>;
|
||||
updateEventDetails: (id: string, data: any) => Promise<void>;
|
||||
functions: { id: string; nome: string }[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const DataContext = createContext<DataContextType | undefined>(undefined);
|
||||
|
|
@ -623,6 +624,8 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({
|
|||
const [institutions, setInstitutions] =
|
||||
useState<Institution[]>(INITIAL_INSTITUTIONS);
|
||||
const [courses, setCourses] = useState<Course[]>(INITIAL_COURSES);
|
||||
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
|
||||
const [pendingUsers, setPendingUsers] = useState<User[]>([]);
|
||||
const [professionals, setProfessionals] = useState<Professional[]>([]);
|
||||
|
|
@ -635,6 +638,7 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({
|
|||
const visibleToken = token || localStorage.getItem("token");
|
||||
|
||||
if (visibleToken) {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Import dynamic to avoid circular dependency if any, or just use imported service
|
||||
const { getAgendas, getFunctions } = await import("../services/apiService");
|
||||
|
|
@ -733,6 +737,8 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({
|
|||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch events", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -1091,6 +1097,22 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({
|
|||
institutions,
|
||||
courses,
|
||||
pendingUsers,
|
||||
addEvent,
|
||||
updateEventStatus,
|
||||
assignPhotographer,
|
||||
getEventsByRole,
|
||||
addInstitution,
|
||||
updateInstitution,
|
||||
getInstitutionsByUserId,
|
||||
getInstitutionById,
|
||||
addCourse,
|
||||
updateCourse,
|
||||
getCoursesByInstitutionId,
|
||||
getActiveCoursesByInstitutionId,
|
||||
getCourseById,
|
||||
registerPendingUser,
|
||||
approveUser,
|
||||
rejectUser,
|
||||
professionals,
|
||||
respondToAssignment: async (eventId, status, reason) => {
|
||||
const token = localStorage.getItem('token');
|
||||
|
|
@ -1162,23 +1184,8 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({
|
|||
}
|
||||
} catch (e) { console.error("Refresh failed", e); }
|
||||
},
|
||||
addEvent,
|
||||
updateEventStatus,
|
||||
functions,
|
||||
assignPhotographer,
|
||||
getEventsByRole,
|
||||
addInstitution,
|
||||
updateInstitution,
|
||||
getInstitutionsByUserId,
|
||||
getInstitutionById,
|
||||
addCourse,
|
||||
updateCourse,
|
||||
getCoursesByInstitutionId,
|
||||
getActiveCoursesByInstitutionId,
|
||||
getCourseById,
|
||||
registerPendingUser,
|
||||
approveUser,
|
||||
rejectUser,
|
||||
isLoading,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -2,11 +2,13 @@ import React, { useState } from 'react';
|
|||
import * as XLSX from 'xlsx';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
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";
|
||||
|
||||
interface ImportInput {
|
||||
type ImportType = 'fot' | 'agenda';
|
||||
|
||||
interface ImportFotInput {
|
||||
fot: string;
|
||||
empresa_nome: string;
|
||||
curso_nome: string;
|
||||
|
|
@ -19,14 +21,51 @@ interface ImportInput {
|
|||
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 = () => {
|
||||
const { token } = useAuth();
|
||||
const [data, setData] = useState<ImportInput[]>([]);
|
||||
const [preview, setPreview] = useState<ImportInput[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<ImportType>('fot');
|
||||
|
||||
// Generic data state (can be Fot or Agenda)
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [filename, setFilename] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
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 file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
|
@ -40,44 +79,113 @@ export const ImportData: React.FC = () => {
|
|||
const ws = wb.Sheets[wsname];
|
||||
const jsonData = XLSX.utils.sheet_to_json(ws, { header: 1 }) as any[][];
|
||||
|
||||
// Assuming header is row 0
|
||||
// Map columns based on index (A=0, B=1, ... J=9) based on screenshot
|
||||
const mappedData: ImportInput[] = [];
|
||||
let mappedData: any[] = [];
|
||||
|
||||
// Start from row 1 (skip header)
|
||||
for (let i = 1; i < jsonData.length; i++) {
|
||||
const row = jsonData[i];
|
||||
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() : "";
|
||||
|
||||
// Skip empty FOT lines?
|
||||
// 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;
|
||||
};
|
||||
|
||||
const fot = getStr(0);
|
||||
if (!fot) continue;
|
||||
|
||||
// Parse Gastos (Remove 'R$', replace ',' with '.')
|
||||
let gastosStr = getStr(8); // Col I
|
||||
// Remove R$, spaces, thousands separator (.) and replace decimal (,) with .
|
||||
// Example: "R$ 2.500,00" -> "2500.00"
|
||||
gastosStr = gastosStr.replace(/[R$\s.]/g, '').replace(',', '.');
|
||||
const gastos = parseFloat(gastosStr) || 0;
|
||||
if (activeTab === 'fot') {
|
||||
// Parse Gastos
|
||||
let gastosStr = getStr(8);
|
||||
gastosStr = gastosStr.replace(/[R$\s.]/g, '').replace(',', '.');
|
||||
const gastos = parseFloat(gastosStr) || 0;
|
||||
|
||||
const importItem: ImportInput = {
|
||||
fot: fot,
|
||||
empresa_nome: getStr(1), // Col B
|
||||
curso_nome: getStr(2), // Col C
|
||||
observacoes: getStr(3), // Col D
|
||||
instituicao: getStr(4), // Col E
|
||||
ano_formatura_label: getStr(5), // Col F
|
||||
cidade: getStr(6), // Col G
|
||||
estado: getStr(7), // Col H
|
||||
gastos_captacao: gastos, // Col I
|
||||
pre_venda: getStr(9).toLowerCase().includes('sim'), // Col J
|
||||
};
|
||||
mappedData.push(importItem);
|
||||
const item: ImportFotInput = {
|
||||
fot: fot,
|
||||
empresa_nome: getStr(1),
|
||||
curso_nome: getStr(2),
|
||||
observacoes: getStr(3),
|
||||
instituicao: getStr(4),
|
||||
ano_formatura_label: getStr(5),
|
||||
cidade: getStr(6),
|
||||
estado: getStr(7),
|
||||
gastos_captacao: gastos,
|
||||
pre_venda: getStr(9).toLowerCase().includes('sim'),
|
||||
};
|
||||
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);
|
||||
setPreview(mappedData.slice(0, 5));
|
||||
setResult(null);
|
||||
};
|
||||
reader.readAsBinaryString(file);
|
||||
|
|
@ -87,7 +195,8 @@ export const ImportData: React.FC = () => {
|
|||
if (!token) return;
|
||||
setIsLoading(true);
|
||||
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",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
|
@ -101,11 +210,18 @@ export const ImportData: React.FC = () => {
|
|||
}
|
||||
|
||||
const resData = await response.json();
|
||||
setResult({
|
||||
success: resData.SuccessCount,
|
||||
errors: resData.Errors || []
|
||||
});
|
||||
// Clear data on success? Maybe keep for review.
|
||||
// Agenda response might be different?
|
||||
// Fot response: {SuccessCount, Errors}.
|
||||
// Agenda response: {message}. I should unifiy or handle both.
|
||||
|
||||
if (resData.message) {
|
||||
setResult({ success: data.length, errors: [] }); // Assume all success if message only
|
||||
} else {
|
||||
setResult({
|
||||
success: resData.SuccessCount,
|
||||
errors: resData.Errors || []
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Import error:", error);
|
||||
alert("Erro ao importar dados. Verifique o console.");
|
||||
|
|
@ -126,20 +242,13 @@ export const ImportData: React.FC = () => {
|
|||
setStartX(e.pageX - tableContainerRef.current.offsetLeft);
|
||||
setScrollLeft(tableContainerRef.current.scrollLeft);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => { setIsDragging(false); };
|
||||
const handleMouseUp = () => { setIsDragging(false); };
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (!isDragging || !tableContainerRef.current) return;
|
||||
e.preventDefault();
|
||||
const x = e.pageX - tableContainerRef.current.offsetLeft;
|
||||
const walk = (x - startX) * 2; // Scroll-fast
|
||||
const walk = (x - startX) * 2;
|
||||
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="max-w-6xl mx-auto space-y-8">
|
||||
<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">
|
||||
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>
|
||||
<div className="mt-2 text-sm text-gray-500 bg-blue-50 p-4 rounded-md">
|
||||
<strong>Colunas Esperadas (A-J):</strong> FOT, Empresa, Curso, Observações, Instituição, Ano Formatura, Cidade, Estado, Gastos Captação, Pré Venda.
|
||||
</div>
|
||||
</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 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="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">
|
||||
<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>
|
||||
</div>
|
||||
<Button onClick={handleImport} isLoading={isLoading}>
|
||||
|
|
@ -187,7 +335,6 @@ export const ImportData: React.FC = () => {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Container with Drag Support */}
|
||||
<div
|
||||
ref={tableContainerRef}
|
||||
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">
|
||||
<thead className="bg-gray-50 sticky top-0 z-10 shadow-sm">
|
||||
<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>
|
||||
<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 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 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 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 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 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>
|
||||
{activeTab === 'fot' ? (
|
||||
<>
|
||||
<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">Empresa</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 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">Ano</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>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{data.map((row, idx) => (
|
||||
<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>
|
||||
<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 text-gray-500">{row.instituicao}</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.cidade}/{row.estado}</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.pre_venda ? 'Sim' : 'Não'}</td>
|
||||
{activeTab === 'fot' ? (
|
||||
<>
|
||||
<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.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 text-gray-500">{row.instituicao}</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">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>
|
||||
))}
|
||||
</tbody>
|
||||
|
|
@ -255,7 +426,7 @@ export const ImportData: React.FC = () => {
|
|||
</div>
|
||||
)}
|
||||
<div className="mt-4">
|
||||
<Button variant="outline" onClick={() => { setData([]); setPreview([]); setResult(null); setFilename(""); }}>
|
||||
<Button variant="outline" onClick={() => { setData([]); setResult(null); setFilename(""); }}>
|
||||
Importar Novo Arquivo
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue