diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 3c49513..0647f31 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -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) diff --git a/backend/internal/agenda/handler.go b/backend/internal/agenda/handler.go index 682aa9a..2ca5805 100644 --- a/backend/internal/agenda/handler.go +++ b/backend/internal/agenda/handler.go @@ -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"}) +} diff --git a/backend/internal/agenda/service.go b/backend/internal/agenda/service.go index 935ceb9..1ea4541 100644 --- a/backend/internal/agenda/service.go +++ b/backend/internal/agenda/service.go @@ -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 +} diff --git a/backend/internal/db/generated/agenda.sql.go b/backend/internal/db/generated/agenda.sql.go index 12b22ac..eec533d 100644 --- a/backend/internal/db/generated/agenda.sql.go +++ b/backend/internal/db/generated/agenda.sql.go @@ -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 diff --git a/backend/internal/db/generated/tipos_eventos.sql.go b/backend/internal/db/generated/tipos_eventos.sql.go index 47619c0..54e72c3 100644 --- a/backend/internal/db/generated/tipos_eventos.sql.go +++ b/backend/internal/db/generated/tipos_eventos.sql.go @@ -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 diff --git a/backend/internal/db/queries/agenda.sql b/backend/internal/db/queries/agenda.sql index d53888f..0a70d2e 100644 --- a/backend/internal/db/queries/agenda.sql +++ b/backend/internal/db/queries/agenda.sql @@ -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; \ No newline at end of file +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; \ No newline at end of file diff --git a/backend/internal/db/queries/tipos_eventos.sql b/backend/internal/db/queries/tipos_eventos.sql index 5f0fcdd..d57b4a1 100644 --- a/backend/internal/db/queries/tipos_eventos.sql +++ b/backend/internal/db/queries/tipos_eventos.sql @@ -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; diff --git a/frontend/App.tsx b/frontend/App.tsx index d7844e9..afe86c1 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -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 ; + } return ( <> diff --git a/frontend/components/EventTable.tsx b/frontend/components/EventTable.tsx index 66caa55..95233ee 100644 --- a/frontend/components/EventTable.tsx +++ b/frontend/components/EventTable.tsx @@ -188,6 +188,21 @@ export const EventTable: React.FC = ({ 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 = ({ return statusLabels[status] || status; }; + // Scroll Sync Logic + const tableContainerRef = React.useRef(null); + const topScrollRef = React.useRef(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 ( -
+
{/* Mobile Card View */}
{sortedEvents.map((event) => { @@ -329,7 +368,19 @@ export const EventTable: React.FC = ({ })}
-
+
+
0 ? tableScrollWidth : '100%', height: '1px' }}>
+
+ +
@@ -454,7 +505,7 @@ export const EventTable: React.FC = ({ - {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 = ({
+ {/* Pagination Controls */} + {totalPages > 1 && ( +
+
+ + +
+
+
+

+ Mostrando página {currentPage} de{" "} + {totalPages} +

+
+
+ +
+
+
+ )} + {sortedEvents.length === 0 && (

Nenhum evento encontrado.

diff --git a/frontend/components/LoadingScreen.tsx b/frontend/components/LoadingScreen.tsx new file mode 100644 index 0000000..0957512 --- /dev/null +++ b/frontend/components/LoadingScreen.tsx @@ -0,0 +1,21 @@ +import React from 'react'; + +export const LoadingScreen: React.FC = () => { + return ( +
+
+ {/* Animated Rings */} +
+
+ + {/* Logo or Icon in Center */} +
+
+
+
+
+
+

Carregando...

+
+ ); +}; diff --git a/frontend/contexts/AuthContext.tsx b/frontend/contexts/AuthContext.tsx index 588c223..d80bbba 100644 --- a/frontend/contexts/AuthContext.tsx +++ b/frontend/contexts/AuthContext.tsx @@ -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(undefined); @@ -55,10 +56,18 @@ const AuthContext = createContext(undefined); export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const [user, setUser] = useState(null); const [token, setToken] = useState(localStorage.getItem("token")); + + // Initial loading state depends on whether we have a token to verify + const [isLoading, setIsLoading] = useState(!!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 ( - + {children} ); diff --git a/frontend/contexts/DataContext.tsx b/frontend/contexts/DataContext.tsx index 8cc0bbb..e5581c5 100644 --- a/frontend/contexts/DataContext.tsx +++ b/frontend/contexts/DataContext.tsx @@ -610,6 +610,7 @@ interface DataContextType { respondToAssignment: (eventId: string, status: string, reason?: string) => Promise; updateEventDetails: (id: string, data: any) => Promise; functions: { id: string; nome: string }[]; + isLoading: boolean; } const DataContext = createContext(undefined); @@ -623,6 +624,8 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({ const [institutions, setInstitutions] = useState(INITIAL_INSTITUTIONS); const [courses, setCourses] = useState(INITIAL_COURSES); + + const [isLoading, setIsLoading] = useState(false); const [pendingUsers, setPendingUsers] = useState([]); const [professionals, setProfessionals] = useState([]); @@ -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} diff --git a/frontend/pages/ImportData.tsx b/frontend/pages/ImportData.tsx index 89d1597..520b7c2 100644 --- a/frontend/pages/ImportData.tsx +++ b/frontend/pages/ImportData.tsx @@ -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([]); - const [preview, setPreview] = useState([]); + const [activeTab, setActiveTab] = useState('fot'); + + // Generic data state (can be Fot or Agenda) + const [data, setData] = useState([]); const [filename, setFilename] = useState(""); 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) => { 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 = () => {
-

Importação de Dados (FOT)

+

Importação de Dados

- 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.

-
- Colunas Esperadas (A-J): FOT, Empresa, Curso, Observações, Instituição, Ano Formatura, Cidade, Estado, Gastos Captação, Pré Venda. -
+
+ + {/* Tabs */} +
+ +
+ + {/* Info Box */} +
+ {activeTab === 'fot' ? ( + Colunas Esperadas (A-J): + ) : ( + Colunas Esperadas (A-AB): + )} +   + {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." + }
@@ -179,7 +327,7 @@ export const ImportData: React.FC = () => {
-

Pré-visualização

+

Pré-visualização ({activeTab === 'fot' ? 'FOT' : 'Agenda'})

Total: {data.length}
- {/* Scrollable Container with Drag Support */}
{ - - - - - - - - + {activeTab === 'fot' ? ( + <> + + + + + + + + ) : ( + <> + + + + + + + + + )} {data.map((row, idx) => ( - - - - - - - - + {activeTab === 'fot' ? ( + <> + + + + + + + + ) : ( + <> + + + + + + + + + )} ))} @@ -255,7 +426,7 @@ export const ImportData: React.FC = () => { )}
-
FOTEmpresaCursoInstituiçãoAnoCidade/UFGastosPré VendaFOTEmpresaCursoInstituiçãoAnoGastosFOTDataEventoLocalHorárioFormandosLogística
{row.fot}{row.empresa_nome}{row.curso_nome}{row.instituicao}{row.ano_formatura_label}{row.cidade}/{row.estado}R$ {row.gastos_captacao.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}{row.pre_venda ? 'Sim' : 'Não'}{row.fot}{row.empresa_nome}{row.curso_nome}{row.instituicao}{row.ano_formatura_label}R$ {row.gastos_captacao?.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}{row.fot}{row.data}{row.tipo_evento}{row.local}{row.horario}{row.qtd_formandos}{row.logistica_observacoes}