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:
NANDO9322 2026-02-02 12:10:13 -03:00
parent 60155bdf56
commit a6ba63203a
13 changed files with 697 additions and 94 deletions

View file

@ -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)

View file

@ -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"})
}

View file

@ -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
}

View file

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

View file

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

View file

@ -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;

View file

@ -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;

View file

@ -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 (
<>

View file

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

View 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>
);
};

View file

@ -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>
);

View file

@ -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}

View file

@ -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>
)}
&nbsp;
{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>