feat: implementação da Importação de Excel e melhorias na Gestão de FOT

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.
This commit is contained in:
NANDO9322 2026-02-02 11:19:56 -03:00
parent d471b4fc0d
commit 60155bdf56
18 changed files with 674 additions and 17 deletions

View file

@ -205,6 +205,7 @@ func main() {
api.GET("/cadastro-fot/:id", cadastroFotHandler.Get) api.GET("/cadastro-fot/:id", cadastroFotHandler.Get)
api.PUT("/cadastro-fot/:id", cadastroFotHandler.Update) api.PUT("/cadastro-fot/:id", cadastroFotHandler.Update)
api.DELETE("/cadastro-fot/:id", cadastroFotHandler.Delete) api.DELETE("/cadastro-fot/:id", cadastroFotHandler.Delete)
api.POST("/import/fot", cadastroFotHandler.Import)
// Agenda routes - read access for AGENDA_VIEWER // Agenda routes - read access for AGENDA_VIEWER
api.GET("/agenda", agendaHandler.List) api.GET("/agenda", agendaHandler.List)

View file

@ -238,3 +238,23 @@ func (h *Handler) Delete(c *gin.Context) {
} }
c.JSON(http.StatusNoContent, nil) 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)
}

View file

@ -119,3 +119,95 @@ func toPgNumeric(f float64) pgtype.Numeric {
} }
return n 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
}

View file

@ -42,6 +42,17 @@ func (q *Queries) GetAnoFormaturaByID(ctx context.Context, id pgtype.UUID) (Anos
return i, err 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 const listAnosFormaturas = `-- name: ListAnosFormaturas :many
SELECT id, ano_semestre, criado_em FROM anos_formaturas ORDER BY ano_semestre SELECT id, ano_semestre, criado_em FROM anos_formaturas ORDER BY ano_semestre
` `

View file

@ -16,7 +16,19 @@ INSERT INTO cadastro_fot (
fot, empresa_id, curso_id, ano_formatura_id, instituicao, cidade, estado, observacoes, gastos_captacao, pre_venda fot, empresa_id, curso_id, ano_formatura_id, instituicao, cidade, estado, observacoes, gastos_captacao, pre_venda
) VALUES ( ) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10 $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 { type CreateCadastroFotParams struct {

View file

@ -42,6 +42,17 @@ func (q *Queries) GetCursoByID(ctx context.Context, id pgtype.UUID) (Curso, erro
return i, err 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 const listCursos = `-- name: ListCursos :many
SELECT id, nome, criado_em FROM cursos ORDER BY nome SELECT id, nome, criado_em FROM cursos ORDER BY nome
` `

View file

@ -42,6 +42,17 @@ func (q *Queries) GetEmpresaByID(ctx context.Context, id pgtype.UUID) (Empresa,
return i, err 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 const listEmpresas = `-- name: ListEmpresas :many
SELECT id, nome, criado_em FROM empresas ORDER BY nome SELECT id, nome, criado_em FROM empresas ORDER BY nome
` `

View file

@ -12,3 +12,6 @@ UPDATE anos_formaturas SET ano_semestre = $2 WHERE id = $1 RETURNING *;
-- name: DeleteAnoFormatura :exec -- name: DeleteAnoFormatura :exec
DELETE FROM anos_formaturas WHERE id = $1; DELETE FROM anos_formaturas WHERE id = $1;
-- name: GetAnoFormaturaByNome :one
SELECT * FROM anos_formaturas WHERE ano_semestre = $1;

View file

@ -3,7 +3,19 @@ INSERT INTO cadastro_fot (
fot, empresa_id, curso_id, ano_formatura_id, instituicao, cidade, estado, observacoes, gastos_captacao, pre_venda fot, empresa_id, curso_id, ano_formatura_id, instituicao, cidade, estado, observacoes, gastos_captacao, pre_venda
) VALUES ( ) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10 $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 -- name: ListCadastroFot :many
SELECT SELECT

View file

@ -12,3 +12,6 @@ UPDATE cursos SET nome = $2 WHERE id = $1 RETURNING *;
-- name: DeleteCurso :exec -- name: DeleteCurso :exec
DELETE FROM cursos WHERE id = $1; DELETE FROM cursos WHERE id = $1;
-- name: GetCursoByNome :one
SELECT * FROM cursos WHERE nome = $1;

View file

@ -12,3 +12,6 @@ UPDATE empresas SET nome = $2 WHERE id = $1 RETURNING *;
-- name: DeleteEmpresa :exec -- name: DeleteEmpresa :exec
DELETE FROM empresas WHERE id = $1; DELETE FROM empresas WHERE id = $1;
-- name: GetEmpresaByNome :one
SELECT * FROM empresas WHERE nome = $1;

View file

@ -166,7 +166,7 @@ CREATE TABLE IF NOT EXISTS cadastro_fot (
cidade VARCHAR(255), cidade VARCHAR(255),
estado VARCHAR(2), estado VARCHAR(2),
observacoes TEXT, observacoes TEXT,
gastos_captacao NUMERIC(10, 2), gastos_captacao NUMERIC(15, 2),
pre_venda BOOLEAN, pre_venda BOOLEAN,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
@ -477,6 +477,11 @@ DO $$
BEGIN BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='agenda' AND column_name='logistica_notificacao_enviada_em') THEN 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; ALTER TABLE agenda ADD COLUMN logistica_notificacao_enviada_em TIMESTAMP;
END IF; END IF;
END END
$$; $$;
DO $$
BEGIN
ALTER TABLE cadastro_fot ALTER COLUMN gastos_captacao TYPE NUMERIC(20, 2);
END $$;

View file

@ -34,6 +34,7 @@ import { X } from "lucide-react";
import { ShieldAlert } from "lucide-react"; import { ShieldAlert } from "lucide-react";
import ProfessionalStatement from "./pages/ProfessionalStatement"; import ProfessionalStatement from "./pages/ProfessionalStatement";
import { ProfilePage } from "./pages/Profile"; import { ProfilePage } from "./pages/Profile";
import { ImportData } from "./pages/ImportData";
// Componente de acesso negado // Componente de acesso negado
const AccessDenied: React.FC = () => { const AccessDenied: React.FC = () => {
@ -741,6 +742,18 @@ const AppContent: React.FC = () => {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/importacao"
element={
<ProtectedRoute
allowedRoles={[UserRole.SUPERADMIN, UserRole.BUSINESS_OWNER]}
>
<PageWrapper>
<ImportData />
</PageWrapper>
</ProtectedRoute>
}
/>
<Route <Route
path="/profile" path="/profile"

View file

@ -1,10 +1,11 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { SimpleCrud } from "./SimpleCrud"; import { SimpleCrud } from "./SimpleCrud";
import { PriceTableEditor } from "./PriceTableEditor"; import { PriceTableEditor } from "./PriceTableEditor";
import { Building2, GraduationCap, Calendar, DollarSign, Database, Briefcase } from "lucide-react"; import { ImportData } from "../../pages/ImportData";
import { Building2, GraduationCap, Calendar, DollarSign, Database, Briefcase, Upload } from "lucide-react";
export const SystemSettings: React.FC = () => { export const SystemSettings: 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 = [ const tabs = [
{ id: "empresas", label: "Empresas", icon: Building2 }, { 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: "anos_formatura", label: "Anos de Formatura", icon: Database },
{ id: "funcoes", label: "Funções", icon: Briefcase }, { id: "funcoes", label: "Funções", icon: Briefcase },
{ id: "precos", label: "Tabela de Preços", icon: DollarSign }, { id: "precos", label: "Tabela de Preços", icon: DollarSign },
{ id: "importacao", label: "Importação", icon: Upload },
]; ];
return ( return (
@ -79,6 +81,11 @@ export const SystemSettings: React.FC = () => {
{activeTab === "precos" && ( {activeTab === "precos" && (
<PriceTableEditor /> <PriceTableEditor />
)} )}
{activeTab === "importacao" && (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<ImportData />
</div>
)}
</div> </div>
</div> </div>
); );

View file

@ -15,7 +15,8 @@
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-router-dom": "^7.9.6" "react-router-dom": "^7.9.6",
"xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
@ -1341,6 +1342,15 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" "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": { "node_modules/agent-base": {
"version": "7.1.4", "version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@ -1489,12 +1499,34 @@
], ],
"license": "CC-BY-4.0" "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": { "node_modules/cheap-ruler": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/cheap-ruler/-/cheap-ruler-4.0.0.tgz", "resolved": "https://registry.npmjs.org/cheap-ruler/-/cheap-ruler-4.0.0.tgz",
"integrity": "sha512-0BJa8f4t141BYKQyn9NSQt1PguFQXMXwZiA5shfoaBYHAb2fFk2RAX+tiWMoQU+Agtzt3mdt0JtuyshAXqZ+Vw==", "integrity": "sha512-0BJa8f4t141BYKQyn9NSQt1PguFQXMXwZiA5shfoaBYHAb2fFk2RAX+tiWMoQU+Agtzt3mdt0JtuyshAXqZ+Vw==",
"license": "ISC" "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": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -1533,6 +1565,18 @@
"url": "https://opencollective.com/express" "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": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -1746,6 +1790,15 @@
"node": ">=12.20.0" "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": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -2548,6 +2601,18 @@
"integrity": "sha512-D50hKrjZgBzqD3FT2Ek53f2dcDLAQT8SSGrzj3vidNH5ISRgceeGVJ2dQIthKOuayqFXfFjXheHNo4bbt9LhRQ==", "integrity": "sha512-D50hKrjZgBzqD3FT2Ek53f2dcDLAQT8SSGrzj3vidNH5ISRgceeGVJ2dQIthKOuayqFXfFjXheHNo4bbt9LhRQ==",
"license": "MIT" "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": { "node_modules/string-width": {
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
@ -2827,6 +2892,24 @@
"node": ">= 8" "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": { "node_modules/wrap-ansi": {
"version": "8.1.0", "version": "8.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "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": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View file

@ -16,7 +16,8 @@
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-router-dom": "^7.9.6" "react-router-dom": "^7.9.6",
"xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.14.0", "@types/node": "^22.14.0",

View file

@ -55,9 +55,9 @@ export const CourseManagement: React.FC = () => {
const lowerTerm = searchTerm.toLowerCase(); const lowerTerm = searchTerm.toLowerCase();
const filtered = fotList.filter(item => const filtered = fotList.filter(item =>
item.fot.toString().includes(lowerTerm) || item.fot.toString().includes(lowerTerm) ||
item.empresa_nome.toLowerCase().includes(lowerTerm) || (item.empresa_nome || "").toLowerCase().includes(lowerTerm) ||
item.curso_nome.toLowerCase().includes(lowerTerm) || (item.curso_nome || "").toLowerCase().includes(lowerTerm) ||
item.instituicao.toLowerCase().includes(lowerTerm) (item.instituicao || "").toLowerCase().includes(lowerTerm)
); );
setFilteredList(filtered); setFilteredList(filtered);
} }
@ -149,6 +149,61 @@ export const CourseManagement: React.FC = () => {
// Extract existing FOT numbers for uniqueness validation // Extract existing FOT numbers for uniqueness validation
const existingFots = fotList.map(item => item.fot); const existingFots = fotList.map(item => item.fot);
// Drag scroll logic
const tableContainerRef = React.useRef<HTMLDivElement>(null);
const topScrollRef = React.useRef<HTMLDivElement>(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) { if (!isAdmin) {
return ( return (
<div className="min-h-screen bg-gray-50 pt-32 pb-12"> <div className="min-h-screen bg-gray-50 pt-32 pb-12">
@ -190,8 +245,8 @@ export const CourseManagement: React.FC = () => {
</div> </div>
{/* Filters Bar */} {/* Filters Bar */}
<div className="bg-white p-4 rounded-lg border border-gray-200 shadow-sm flex items-center gap-4"> <div className="bg-white p-4 rounded-lg border border-gray-200 shadow-sm flex items-center justify-between gap-4 flex-wrap">
<div className="relative flex-1 max-w-md"> <div className="relative flex-1 max-w-md min-w-[200px]">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={18} /> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={18} />
<input <input
type="text" type="text"
@ -201,7 +256,9 @@ export const CourseManagement: React.FC = () => {
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
/> />
</div> </div>
{/* Can add more filters here later */} <span className="text-sm font-semibold bg-gray-100 px-3 py-1 rounded-full text-gray-700">
Total: {filteredList.length}
</span>
</div> </div>
</div> </div>
@ -230,13 +287,14 @@ export const CourseManagement: React.FC = () => {
)} )}
{/* Courses Table */} {/* Courses Table */}
<div className="bg-white rounded-lg shadow border border-gray-200 overflow-hidden"> <div className="bg-white rounded-lg shadow border border-gray-200 overflow-hidden flex flex-col">
{isLoading ? ( {isLoading ? (
<div className="p-12 text-center text-gray-500"> <div className="p-12 text-center text-gray-500">
Carregando dados... Carregando dados...
</div> </div>
) : ( ) : (
<> <>
{/* Mobile List View */}
<div className="md:hidden divide-y divide-gray-100"> <div className="md:hidden divide-y divide-gray-100">
{filteredList.length === 0 ? ( {filteredList.length === 0 ? (
<div className="p-8 text-center text-gray-500"> <div className="p-8 text-center text-gray-500">
@ -296,9 +354,27 @@ export const CourseManagement: React.FC = () => {
)} )}
</div> </div>
<div className="hidden md:block overflow-x-auto"> {/* Top Scrollbar (Desktop) */}
<div
ref={topScrollRef}
className="hidden md:block overflow-x-auto border-b border-gray-200 bg-gray-50 mb-1"
onScroll={handleTopScroll}
style={{ minHeight: '12px' }}
>
<div style={{ width: tableScrollWidth > 0 ? tableScrollWidth : '100%', height: '1px' }}></div>
</div>
<div
ref={tableContainerRef}
className={`hidden md:block overflow-x-auto ${isDragging ? 'cursor-grabbing' : 'cursor-grab'}`}
onMouseDown={handleMouseDown}
onMouseLeave={handleMouseLeave}
onMouseUp={handleMouseUp}
onMouseMove={handleMouseMove}
onScroll={handleTableScroll}
>
<table className="min-w-full divide-y divide-gray-200"> <table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50"> <thead className="bg-gray-50 select-none">
<tr> <tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
FOT FOT
@ -466,6 +542,11 @@ export const CourseManagement: React.FC = () => {
</tbody> </tbody>
</table> </table>
</div> </div>
<div className="px-6 py-4 border-t border-gray-200 bg-gray-50 text-right">
<span className="text-sm font-semibold text-gray-700">
Total de Registros: {filteredList.length}
</span>
</div>
</> </>
)} )}
</div> </div>

View file

@ -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<ImportInput[]>([]);
const [preview, setPreview] = useState<ImportInput[]>([]);
const [filename, setFilename] = useState<string>("");
const [isLoading, setIsLoading] = useState(false);
const [result, setResult] = useState<{ success: number; errors: string[] } | null>(null);
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
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<HTMLDivElement>(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 (
<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>
<p className="mt-2 text-gray-600">
Importe dados da planilha Excel para o sistema. Certifique-se que as colunas seguem o padrão.
</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>
<div className="bg-white p-6 rounded-lg shadow space-y-4">
<div className="flex items-center justify-center w-full">
<label htmlFor="dropzone-file" className="flex flex-col items-center justify-center w-full h-32 border-2 border-gray-300 border-dashed rounded-lg cursor-pointer bg-gray-50 hover:bg-gray-100">
<div className="flex flex-col items-center justify-center pt-5 pb-6">
<Upload className="w-8 h-8 mb-3 text-gray-400" />
<p className="mb-2 text-sm text-gray-500"><span className="font-semibold">Clique para enviar</span> ou arraste o arquivo</p>
<p className="text-xs text-gray-500">XLSX, XLS (Excel)</p>
</div>
<input id="dropzone-file" type="file" className="hidden" accept=".xlsx, .xls" onChange={handleFileUpload} />
</label>
</div>
{filename && (
<div className="flex items-center gap-2 text-sm text-gray-700">
<FileText className="w-4 h-4" />
Arquivo selecionado: <span className="font-medium">{filename}</span> ({data.length} registros)
</div>
)}
</div>
{data.length > 0 && !result && (
<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>
<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}>
Confirmar Importação
</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'}`}
onMouseDown={handleMouseDown}
onMouseLeave={handleMouseLeave}
onMouseUp={handleMouseUp}
onMouseMove={handleMouseMove}
>
<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>
</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>
</tr>
))}
</tbody>
</table>
</div>
<div className="px-6 py-4 border-t border-gray-200 bg-gray-50 text-right">
<span className="text-sm font-semibold text-gray-700">Total de Registros: {data.length}</span>
</div>
</div>
)}
{result && (
<div className={`p-4 rounded-md ${result.errors.length > 0 ? 'bg-yellow-50' : 'bg-green-50'}`}>
<div className="flex items-center mb-2">
{result.errors.length === 0 ? (
<CheckCircle className="w-6 h-6 text-green-500 mr-2" />
) : (
<AlertTriangle className="w-6 h-6 text-yellow-500 mr-2" />
)}
<h3 className="text-lg font-medium text-gray-900">Resultado da Importação</h3>
</div>
<p className="text-sm text-gray-700 mb-2">
Sucesso: <strong>{result.success}</strong> registros importados/atualizados.
</p>
{result.errors.length > 0 && (
<div className="mt-2">
<p className="text-sm font-bold text-red-600 mb-1">Erros ({result.errors.length}):</p>
<ul className="list-disc pl-5 text-xs text-red-600 max-h-40 overflow-y-auto">
{result.errors.map((err, idx) => (
<li key={idx}>{err}</li>
))}
</ul>
</div>
)}
<div className="mt-4">
<Button variant="outline" onClick={() => { setData([]); setPreview([]); setResult(null); setFilename(""); }}>
Importar Novo Arquivo
</Button>
</div>
</div>
)}
</div>
</div>
);
};