From 60155bdf56b217dbc175e5bf7c403c13232b4388 Mon Sep 17 00:00:00 2001 From: NANDO9322 Date: Mon, 2 Feb 2026 11:19:56 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20implementa=C3=A7=C3=A3o=20da=20Importa?= =?UTF-8?q?=C3=A7=C3=A3o=20de=20Excel=20e=20melhorias=20na=20Gest=C3=A3o?= =?UTF-8?q?=20de=20FOT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Implementa rota e serviço de importação em lote (`/api/import/fot`). - Adiciona suporte a "Upsert" para atualizar registros existentes sem duplicar. - Corrige e migra schema do banco: ajuste na precisão de valores monetários e correções de sintaxe. Frontend: - Cria página de Importação de Dados com visualização de log e tratamento de erros. - Implementa melhorias de UX nas tabelas (Importação e Gestão de FOT): - Contadores de total de registros. - Funcionalidade "Drag-to-Scroll" (arrastar para rolar). - Barra de rolagem superior sincronizada na tabela de gestão. - Corrige bug de "tela branca" ao filtrar dados vazios na gestão. --- backend/cmd/api/main.go | 1 + backend/internal/cadastro_fot/handler.go | 20 ++ backend/internal/cadastro_fot/service.go | 92 ++++++ .../db/generated/anos_formaturas.sql.go | 11 + .../internal/db/generated/cadastro_fot.sql.go | 14 +- backend/internal/db/generated/cursos.sql.go | 11 + backend/internal/db/generated/empresas.sql.go | 11 + .../internal/db/queries/anos_formaturas.sql | 3 + backend/internal/db/queries/cadastro_fot.sql | 14 +- backend/internal/db/queries/cursos.sql | 3 + backend/internal/db/queries/empresas.sql | 3 + backend/internal/db/schema.sql | 9 +- frontend/App.tsx | 13 + frontend/components/System/SystemSettings.tsx | 11 +- frontend/package-lock.json | 106 ++++++- frontend/package.json | 3 +- frontend/pages/CourseManagement.tsx | 99 ++++++- frontend/pages/ImportData.tsx | 267 ++++++++++++++++++ 18 files changed, 674 insertions(+), 17 deletions(-) create mode 100644 frontend/pages/ImportData.tsx diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 70e0e7c..3c49513 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -205,6 +205,7 @@ func main() { api.GET("/cadastro-fot/:id", cadastroFotHandler.Get) api.PUT("/cadastro-fot/:id", cadastroFotHandler.Update) api.DELETE("/cadastro-fot/:id", cadastroFotHandler.Delete) + api.POST("/import/fot", cadastroFotHandler.Import) // Agenda routes - read access for AGENDA_VIEWER api.GET("/agenda", agendaHandler.List) diff --git a/backend/internal/cadastro_fot/handler.go b/backend/internal/cadastro_fot/handler.go index ec0c71f..c564d5c 100644 --- a/backend/internal/cadastro_fot/handler.go +++ b/backend/internal/cadastro_fot/handler.go @@ -238,3 +238,23 @@ func (h *Handler) Delete(c *gin.Context) { } c.JSON(http.StatusNoContent, nil) } + +// Import godoc +// @Summary Import FOT data +// @Tags cadastro_fot +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param request body []ImportInput true "List of FOTs" +// @Success 200 {object} ImportResult +// @Router /api/import/fot [post] +func (h *Handler) Import(c *gin.Context) { + var req []ImportInput + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + res := h.service.BatchImport(c.Request.Context(), req) + c.JSON(http.StatusOK, res) +} diff --git a/backend/internal/cadastro_fot/service.go b/backend/internal/cadastro_fot/service.go index e0ebfba..a6a6752 100644 --- a/backend/internal/cadastro_fot/service.go +++ b/backend/internal/cadastro_fot/service.go @@ -119,3 +119,95 @@ func toPgNumeric(f float64) pgtype.Numeric { } return n } + +// ImportInput for Batch Import +type ImportInput struct { + Fot string `json:"fot"` + EmpresaNome string `json:"empresa_nome"` + CursoNome string `json:"curso_nome"` + AnoFormaturaLabel string `json:"ano_formatura_label"` + Instituicao string `json:"instituicao"` + Cidade string `json:"cidade"` + Estado string `json:"estado"` + Observacoes string `json:"observacoes"` + GastosCaptacao float64 `json:"gastos_captacao"` + PreVenda bool `json:"pre_venda"` +} + +type ImportResult struct { + SuccessCount int + Errors []string +} + +func (s *Service) BatchImport(ctx context.Context, items []ImportInput) ImportResult { + result := ImportResult{Errors: []string{}} + + for i, item := range items { + // 1. Resolve Empresa + empresa, err := s.queries.GetEmpresaByNome(ctx, item.EmpresaNome) + var empresaID pgtype.UUID + if err != nil { + // Create + newEmp, errCreate := s.queries.CreateEmpresa(ctx, item.EmpresaNome) + if errCreate != nil { + result.Errors = append(result.Errors, "Row "+strconv.Itoa(i+1)+": Error creating empresa: "+errCreate.Error()) + continue + } + empresaID = newEmp.ID + } else { + empresaID = empresa.ID + } + + // 2. Resolve Curso + curso, err := s.queries.GetCursoByNome(ctx, item.CursoNome) + var cursoID pgtype.UUID + if err != nil { + newCurso, errCreate := s.queries.CreateCurso(ctx, item.CursoNome) + if errCreate != nil { + result.Errors = append(result.Errors, "Row "+strconv.Itoa(i+1)+": Error creating curso: "+errCreate.Error()) + continue + } + cursoID = newCurso.ID + } else { + cursoID = curso.ID + } + + // 3. Resolve Ano Formatura + ano, err := s.queries.GetAnoFormaturaByNome(ctx, item.AnoFormaturaLabel) + var anoID pgtype.UUID + if err != nil { + newAno, errCreate := s.queries.CreateAnoFormatura(ctx, item.AnoFormaturaLabel) + if errCreate != nil { + result.Errors = append(result.Errors, "Row "+strconv.Itoa(i+1)+": Error creating ano: "+errCreate.Error()) + continue + } + anoID = newAno.ID + } else { + anoID = ano.ID + } + + // 4. Insert Cadastro FOT + // Check if exists? Or try create and catch duplicate + // Ideally we update if exists? Let's just try Create for now. + _, err = s.queries.CreateCadastroFot(ctx, generated.CreateCadastroFotParams{ + Fot: item.Fot, + EmpresaID: empresaID, + CursoID: cursoID, + AnoFormaturaID: anoID, + Instituicao: pgtype.Text{String: item.Instituicao, Valid: true}, + Cidade: pgtype.Text{String: item.Cidade, Valid: true}, + Estado: pgtype.Text{String: item.Estado, Valid: true}, + Observacoes: pgtype.Text{String: item.Observacoes, Valid: true}, + GastosCaptacao: toPgNumeric(item.GastosCaptacao), + PreVenda: pgtype.Bool{Bool: item.PreVenda, Valid: true}, + }) + + if err != nil { + result.Errors = append(result.Errors, "Row "+strconv.Itoa(i+1)+": Error inserting FOT "+item.Fot+": "+err.Error()) + } else { + result.SuccessCount++ + } + } + + return result +} diff --git a/backend/internal/db/generated/anos_formaturas.sql.go b/backend/internal/db/generated/anos_formaturas.sql.go index 801b764..5b5e6e1 100644 --- a/backend/internal/db/generated/anos_formaturas.sql.go +++ b/backend/internal/db/generated/anos_formaturas.sql.go @@ -42,6 +42,17 @@ func (q *Queries) GetAnoFormaturaByID(ctx context.Context, id pgtype.UUID) (Anos return i, err } +const getAnoFormaturaByNome = `-- name: GetAnoFormaturaByNome :one +SELECT id, ano_semestre, criado_em FROM anos_formaturas WHERE ano_semestre = $1 +` + +func (q *Queries) GetAnoFormaturaByNome(ctx context.Context, anoSemestre string) (AnosFormatura, error) { + row := q.db.QueryRow(ctx, getAnoFormaturaByNome, anoSemestre) + var i AnosFormatura + err := row.Scan(&i.ID, &i.AnoSemestre, &i.CriadoEm) + return i, err +} + const listAnosFormaturas = `-- name: ListAnosFormaturas :many SELECT id, ano_semestre, criado_em FROM anos_formaturas ORDER BY ano_semestre ` diff --git a/backend/internal/db/generated/cadastro_fot.sql.go b/backend/internal/db/generated/cadastro_fot.sql.go index 093dc99..f0d684f 100644 --- a/backend/internal/db/generated/cadastro_fot.sql.go +++ b/backend/internal/db/generated/cadastro_fot.sql.go @@ -16,7 +16,19 @@ INSERT INTO cadastro_fot ( fot, empresa_id, curso_id, ano_formatura_id, instituicao, cidade, estado, observacoes, gastos_captacao, pre_venda ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 -) RETURNING id, fot, empresa_id, curso_id, ano_formatura_id, instituicao, cidade, estado, observacoes, gastos_captacao, pre_venda, created_at, updated_at +) +ON CONFLICT (fot) DO UPDATE SET + empresa_id = EXCLUDED.empresa_id, + curso_id = EXCLUDED.curso_id, + ano_formatura_id = EXCLUDED.ano_formatura_id, + instituicao = EXCLUDED.instituicao, + cidade = EXCLUDED.cidade, + estado = EXCLUDED.estado, + observacoes = EXCLUDED.observacoes, + gastos_captacao = EXCLUDED.gastos_captacao, + pre_venda = EXCLUDED.pre_venda, + updated_at = CURRENT_TIMESTAMP +RETURNING id, fot, empresa_id, curso_id, ano_formatura_id, instituicao, cidade, estado, observacoes, gastos_captacao, pre_venda, created_at, updated_at ` type CreateCadastroFotParams struct { diff --git a/backend/internal/db/generated/cursos.sql.go b/backend/internal/db/generated/cursos.sql.go index ce56aa6..1d39fe3 100644 --- a/backend/internal/db/generated/cursos.sql.go +++ b/backend/internal/db/generated/cursos.sql.go @@ -42,6 +42,17 @@ func (q *Queries) GetCursoByID(ctx context.Context, id pgtype.UUID) (Curso, erro return i, err } +const getCursoByNome = `-- name: GetCursoByNome :one +SELECT id, nome, criado_em FROM cursos WHERE nome = $1 +` + +func (q *Queries) GetCursoByNome(ctx context.Context, nome string) (Curso, error) { + row := q.db.QueryRow(ctx, getCursoByNome, nome) + var i Curso + err := row.Scan(&i.ID, &i.Nome, &i.CriadoEm) + return i, err +} + const listCursos = `-- name: ListCursos :many SELECT id, nome, criado_em FROM cursos ORDER BY nome ` diff --git a/backend/internal/db/generated/empresas.sql.go b/backend/internal/db/generated/empresas.sql.go index e3f9f39..9bfff18 100644 --- a/backend/internal/db/generated/empresas.sql.go +++ b/backend/internal/db/generated/empresas.sql.go @@ -42,6 +42,17 @@ func (q *Queries) GetEmpresaByID(ctx context.Context, id pgtype.UUID) (Empresa, return i, err } +const getEmpresaByNome = `-- name: GetEmpresaByNome :one +SELECT id, nome, criado_em FROM empresas WHERE nome = $1 +` + +func (q *Queries) GetEmpresaByNome(ctx context.Context, nome string) (Empresa, error) { + row := q.db.QueryRow(ctx, getEmpresaByNome, nome) + var i Empresa + err := row.Scan(&i.ID, &i.Nome, &i.CriadoEm) + return i, err +} + const listEmpresas = `-- name: ListEmpresas :many SELECT id, nome, criado_em FROM empresas ORDER BY nome ` diff --git a/backend/internal/db/queries/anos_formaturas.sql b/backend/internal/db/queries/anos_formaturas.sql index 8df2463..e60b7e1 100644 --- a/backend/internal/db/queries/anos_formaturas.sql +++ b/backend/internal/db/queries/anos_formaturas.sql @@ -12,3 +12,6 @@ UPDATE anos_formaturas SET ano_semestre = $2 WHERE id = $1 RETURNING *; -- name: DeleteAnoFormatura :exec DELETE FROM anos_formaturas WHERE id = $1; + +-- name: GetAnoFormaturaByNome :one +SELECT * FROM anos_formaturas WHERE ano_semestre = $1; diff --git a/backend/internal/db/queries/cadastro_fot.sql b/backend/internal/db/queries/cadastro_fot.sql index e0c7010..2449de7 100644 --- a/backend/internal/db/queries/cadastro_fot.sql +++ b/backend/internal/db/queries/cadastro_fot.sql @@ -3,7 +3,19 @@ INSERT INTO cadastro_fot ( fot, empresa_id, curso_id, ano_formatura_id, instituicao, cidade, estado, observacoes, gastos_captacao, pre_venda ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 -) RETURNING *; +) +ON CONFLICT (fot) DO UPDATE SET + empresa_id = EXCLUDED.empresa_id, + curso_id = EXCLUDED.curso_id, + ano_formatura_id = EXCLUDED.ano_formatura_id, + instituicao = EXCLUDED.instituicao, + cidade = EXCLUDED.cidade, + estado = EXCLUDED.estado, + observacoes = EXCLUDED.observacoes, + gastos_captacao = EXCLUDED.gastos_captacao, + pre_venda = EXCLUDED.pre_venda, + updated_at = CURRENT_TIMESTAMP +RETURNING *; -- name: ListCadastroFot :many SELECT diff --git a/backend/internal/db/queries/cursos.sql b/backend/internal/db/queries/cursos.sql index 3c85f2c..8313e9f 100644 --- a/backend/internal/db/queries/cursos.sql +++ b/backend/internal/db/queries/cursos.sql @@ -12,3 +12,6 @@ UPDATE cursos SET nome = $2 WHERE id = $1 RETURNING *; -- name: DeleteCurso :exec DELETE FROM cursos WHERE id = $1; + +-- name: GetCursoByNome :one +SELECT * FROM cursos WHERE nome = $1; diff --git a/backend/internal/db/queries/empresas.sql b/backend/internal/db/queries/empresas.sql index d598195..a2c7cc8 100644 --- a/backend/internal/db/queries/empresas.sql +++ b/backend/internal/db/queries/empresas.sql @@ -12,3 +12,6 @@ UPDATE empresas SET nome = $2 WHERE id = $1 RETURNING *; -- name: DeleteEmpresa :exec DELETE FROM empresas WHERE id = $1; + +-- name: GetEmpresaByNome :one +SELECT * FROM empresas WHERE nome = $1; diff --git a/backend/internal/db/schema.sql b/backend/internal/db/schema.sql index aa78d52..3a0f9de 100644 --- a/backend/internal/db/schema.sql +++ b/backend/internal/db/schema.sql @@ -166,7 +166,7 @@ CREATE TABLE IF NOT EXISTS cadastro_fot ( cidade VARCHAR(255), estado VARCHAR(2), observacoes TEXT, - gastos_captacao NUMERIC(10, 2), + gastos_captacao NUMERIC(15, 2), pre_venda BOOLEAN, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP @@ -477,6 +477,11 @@ DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='agenda' AND column_name='logistica_notificacao_enviada_em') THEN ALTER TABLE agenda ADD COLUMN logistica_notificacao_enviada_em TIMESTAMP; - END IF; + END IF; END $$; + +DO $$ +BEGIN + ALTER TABLE cadastro_fot ALTER COLUMN gastos_captacao TYPE NUMERIC(20, 2); +END $$; diff --git a/frontend/App.tsx b/frontend/App.tsx index 8dc3280..d7844e9 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -34,6 +34,7 @@ import { X } from "lucide-react"; import { ShieldAlert } from "lucide-react"; import ProfessionalStatement from "./pages/ProfessionalStatement"; import { ProfilePage } from "./pages/Profile"; +import { ImportData } from "./pages/ImportData"; // Componente de acesso negado const AccessDenied: React.FC = () => { @@ -741,6 +742,18 @@ const AppContent: React.FC = () => { } /> + + + + + + } + /> { - const [activeTab, setActiveTab] = useState<"empresas" | "cursos" | "tipos_evento" | "anos_formatura" | "precos" | "funcoes">("empresas"); + const [activeTab, setActiveTab] = useState<"empresas" | "cursos" | "tipos_evento" | "anos_formatura" | "precos" | "funcoes" | "importacao">("empresas"); const tabs = [ { id: "empresas", label: "Empresas", icon: Building2 }, @@ -13,6 +14,7 @@ export const SystemSettings: React.FC = () => { { id: "anos_formatura", label: "Anos de Formatura", icon: Database }, { id: "funcoes", label: "Funções", icon: Briefcase }, { id: "precos", label: "Tabela de Preços", icon: DollarSign }, + { id: "importacao", label: "Importação", icon: Upload }, ]; return ( @@ -79,6 +81,11 @@ export const SystemSettings: React.FC = () => { {activeTab === "precos" && ( )} + {activeTab === "importacao" && ( +
+ +
+ )} ); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 341a4a9..b49599b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,7 +15,8 @@ "react": "^19.2.0", "react-dom": "^19.2.0", "react-hot-toast": "^2.6.0", - "react-router-dom": "^7.9.6" + "react-router-dom": "^7.9.6", + "xlsx": "^0.18.5" }, "devDependencies": { "@types/node": "^22.14.0", @@ -1341,6 +1342,15 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -1489,12 +1499,34 @@ ], "license": "CC-BY-4.0" }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/cheap-ruler": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/cheap-ruler/-/cheap-ruler-4.0.0.tgz", "integrity": "sha512-0BJa8f4t141BYKQyn9NSQt1PguFQXMXwZiA5shfoaBYHAb2fFk2RAX+tiWMoQU+Agtzt3mdt0JtuyshAXqZ+Vw==", "license": "ISC" }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1533,6 +1565,18 @@ "url": "https://opencollective.com/express" } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1746,6 +1790,15 @@ "node": ">=12.20.0" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2548,6 +2601,18 @@ "integrity": "sha512-D50hKrjZgBzqD3FT2Ek53f2dcDLAQT8SSGrzj3vidNH5ISRgceeGVJ2dQIthKOuayqFXfFjXheHNo4bbt9LhRQ==", "license": "MIT" }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -2827,6 +2892,24 @@ "node": ">= 8" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/wrap-ansi": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", @@ -2939,6 +3022,27 @@ } } }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 2d4c31d..9a42560 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,7 +16,8 @@ "react": "^19.2.0", "react-dom": "^19.2.0", "react-hot-toast": "^2.6.0", - "react-router-dom": "^7.9.6" + "react-router-dom": "^7.9.6", + "xlsx": "^0.18.5" }, "devDependencies": { "@types/node": "^22.14.0", diff --git a/frontend/pages/CourseManagement.tsx b/frontend/pages/CourseManagement.tsx index ee3afa0..b612271 100644 --- a/frontend/pages/CourseManagement.tsx +++ b/frontend/pages/CourseManagement.tsx @@ -55,9 +55,9 @@ export const CourseManagement: React.FC = () => { const lowerTerm = searchTerm.toLowerCase(); const filtered = fotList.filter(item => item.fot.toString().includes(lowerTerm) || - item.empresa_nome.toLowerCase().includes(lowerTerm) || - item.curso_nome.toLowerCase().includes(lowerTerm) || - item.instituicao.toLowerCase().includes(lowerTerm) + (item.empresa_nome || "").toLowerCase().includes(lowerTerm) || + (item.curso_nome || "").toLowerCase().includes(lowerTerm) || + (item.instituicao || "").toLowerCase().includes(lowerTerm) ); setFilteredList(filtered); } @@ -149,6 +149,61 @@ export const CourseManagement: React.FC = () => { // Extract existing FOT numbers for uniqueness validation const existingFots = fotList.map(item => item.fot); + // Drag scroll logic + const tableContainerRef = React.useRef(null); + const topScrollRef = React.useRef(null); // Ref for top scrollbar + const [isDragging, setIsDragging] = useState(false); + const [startX, setStartX] = useState(0); + const [scrollLeft, setScrollLeft] = useState(0); + const [tableScrollWidth, setTableScrollWidth] = useState(0); + + // Update top scrollbar width when data changes + React.useEffect(() => { + if (tableContainerRef.current) { + setTableScrollWidth(tableContainerRef.current.scrollWidth); + } + }, [filteredList, isLoading]); + + // Sync scroll handlers + const handleTableScroll = () => { + if (tableContainerRef.current && topScrollRef.current) { + if (topScrollRef.current.scrollLeft !== tableContainerRef.current.scrollLeft) { + topScrollRef.current.scrollLeft = tableContainerRef.current.scrollLeft; + } + } + }; + + const handleTopScroll = () => { + if (tableContainerRef.current && topScrollRef.current) { + if (tableContainerRef.current.scrollLeft !== topScrollRef.current.scrollLeft) { + tableContainerRef.current.scrollLeft = topScrollRef.current.scrollLeft; + } + } + }; + + const handleMouseDown = (e: React.MouseEvent) => { + if (!tableContainerRef.current) return; + setIsDragging(true); + setStartX(e.pageX - tableContainerRef.current.offsetLeft); + setScrollLeft(tableContainerRef.current.scrollLeft); + }; + + 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 + tableContainerRef.current.scrollLeft = scrollLeft - walk; + }; + if (!isAdmin) { return (
@@ -190,8 +245,8 @@ export const CourseManagement: React.FC = () => {
{/* Filters Bar */} -
-
+
+
{ onChange={(e) => setSearchTerm(e.target.value)} />
- {/* Can add more filters here later */} + + Total: {filteredList.length} +
@@ -230,13 +287,14 @@ export const CourseManagement: React.FC = () => { )} {/* Courses Table */} -
+
{isLoading ? (
Carregando dados...
) : ( <> + {/* Mobile List View */}
{filteredList.length === 0 ? (
@@ -296,9 +354,27 @@ export const CourseManagement: React.FC = () => { )}
-
+ {/* Top Scrollbar (Desktop) */} +
+
0 ? tableScrollWidth : '100%', height: '1px' }}>
+
+ +
- +
FOT @@ -466,6 +542,11 @@ export const CourseManagement: React.FC = () => {
+
+ + Total de Registros: {filteredList.length} + +
)}
diff --git a/frontend/pages/ImportData.tsx b/frontend/pages/ImportData.tsx new file mode 100644 index 0000000..89d1597 --- /dev/null +++ b/frontend/pages/ImportData.tsx @@ -0,0 +1,267 @@ +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'; + +const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8080"; + +interface ImportInput { + fot: string; + empresa_nome: string; + curso_nome: string; + ano_formatura_label: string; + instituicao: string; + cidade: string; + estado: string; + observacoes: string; + gastos_captacao: number; + pre_venda: boolean; +} + +export const ImportData: React.FC = () => { + const { token } = useAuth(); + const [data, setData] = useState([]); + const [preview, setPreview] = useState([]); + const [filename, setFilename] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [result, setResult] = useState<{ success: number; errors: string[] } | null>(null); + + const handleFileUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + setFilename(file.name); + const reader = new FileReader(); + reader.onload = (evt) => { + const bstr = evt.target?.result; + const wb = XLSX.read(bstr, { type: 'binary' }); + const wsname = wb.SheetNames[0]; + 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[] = []; + // 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 + const getStr = (idx: number) => row[idx] ? String(row[idx]).trim() : ""; + + // Skip empty FOT lines? + 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; + + 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); + } + setData(mappedData); + setPreview(mappedData.slice(0, 5)); + setResult(null); + }; + reader.readAsBinaryString(file); + }; + + const handleImport = async () => { + if (!token) return; + setIsLoading(true); + try { + const response = await fetch(`${API_BASE_URL}/api/import/fot`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${token}` + }, + body: JSON.stringify(data) + }); + + if (!response.ok) { + throw new Error(`Erro na importação: ${response.statusText}`); + } + + const resData = await response.json(); + setResult({ + success: resData.SuccessCount, + errors: resData.Errors || [] + }); + // Clear data on success? Maybe keep for review. + } catch (error) { + console.error("Import error:", error); + alert("Erro ao importar dados. Verifique o console."); + } finally { + setIsLoading(false); + } + }; + + // Drag scroll logic + const tableContainerRef = React.useRef(null); + const [isDragging, setIsDragging] = useState(false); + const [startX, setStartX] = useState(0); + const [scrollLeft, setScrollLeft] = useState(0); + + const handleMouseDown = (e: React.MouseEvent) => { + if (!tableContainerRef.current) return; + setIsDragging(true); + setStartX(e.pageX - tableContainerRef.current.offsetLeft); + setScrollLeft(tableContainerRef.current.scrollLeft); + }; + + 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 + tableContainerRef.current.scrollLeft = scrollLeft - walk; + }; + + return ( +
+
+
+

Importação de Dados (FOT)

+

+ Importe dados da planilha Excel para o sistema. Certifique-se que as colunas seguem o padrão. +

+
+ Colunas Esperadas (A-J): FOT, Empresa, Curso, Observações, Instituição, Ano Formatura, Cidade, Estado, Gastos Captação, Pré Venda. +
+
+ +
+
+ +
+ {filename && ( +
+ + Arquivo selecionado: {filename} ({data.length} registros) +
+ )} +
+ + {data.length > 0 && !result && ( +
+
+
+

Pré-visualização

+ Total: {data.length} +
+ +
+ + {/* Scrollable Container with Drag Support */} +
+ + + + + + + + + + + + + + + {data.map((row, idx) => ( + + + + + + + + + + + ))} + +
FOTEmpresaCursoInstituiçãoAnoCidade/UFGastosPré Venda
{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'}
+
+
+ Total de Registros: {data.length} +
+
+ )} + + {result && ( +
0 ? 'bg-yellow-50' : 'bg-green-50'}`}> +
+ {result.errors.length === 0 ? ( + + ) : ( + + )} +

Resultado da Importação

+
+

+ Sucesso: {result.success} registros importados/atualizados. +

+ {result.errors.length > 0 && ( +
+

Erros ({result.errors.length}):

+
    + {result.errors.map((err, idx) => ( +
  • {err}
  • + ))} +
+
+ )} +
+ +
+
+ )} +
+
+ ); +};