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:
parent
d471b4fc0d
commit
60155bdf56
18 changed files with 674 additions and 17 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
`
|
`
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
`
|
`
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
`
|
`
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -480,3 +480,8 @@ BEGIN
|
||||||
END IF;
|
END IF;
|
||||||
END
|
END
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE cadastro_fot ALTER COLUMN gastos_captacao TYPE NUMERIC(20, 2);
|
||||||
|
END $$;
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
106
frontend/package-lock.json
generated
106
frontend/package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
267
frontend/pages/ImportData.tsx
Normal file
267
frontend/pages/ImportData.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
Loading…
Reference in a new issue