Compare commits

...
5 Commits
7 changed files with 548 additions and 53 deletions
+36
View File
@@ -88,3 +88,39 @@ pub fn ler_csv(
avisos, avisos,
}) })
} }
/// Retorna as primeiras `n` linhas brutas do CSV (sem pular cabeçalho).
/// Usado exclusivamente para pré-visualização na UI.
pub fn preview_csv(
caminho: &Path,
delimitador: u8,
encoding: &str,
n: usize,
) -> Result<Vec<Vec<String>>, ErroArquivo> {
let bytes = std::fs::read(caminho)
.map_err(|e| ErroArquivo::ErroLeitura(e.to_string()))?;
let conteudo = match encoding.to_lowercase().as_str() {
"windows-1252" | "latin-1" | "iso-8859-1" => {
let (decoded, _, _) = WINDOWS_1252.decode(&bytes);
decoded.into_owned()
}
_ => String::from_utf8(bytes)
.map_err(|e| ErroArquivo::ErroLeitura(format!("Encoding inválido: {}", e)))?,
};
let mut reader = csv::ReaderBuilder::new()
.delimiter(delimitador)
.has_headers(false)
.flexible(true)
.from_reader(conteudo.as_bytes());
let linhas = reader
.records()
.take(n)
.filter_map(|r| r.ok())
.map(|r| r.iter().map(|s| s.to_string()).collect())
.collect();
Ok(linhas)
}
+47
View File
@@ -97,6 +97,53 @@ pub fn ler_xlsx(
Ok(ResultadoXlsx { linhas, avisos }) Ok(ResultadoXlsx { linhas, avisos })
} }
/// Retorna as primeiras 5 linhas de uma aba XLSX, a partir da linha 1.
/// Usado exclusivamente para pré-visualização na UI.
pub fn preview_xlsx(
caminho: &Path,
nome_aba: &str,
) -> Result<Vec<Vec<String>>, ErroArquivo> {
verificar_tamanho(caminho)?;
let mut workbook = open_workbook_auto(caminho)
.map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
let range: calamine::Range<calamine::Data> = workbook
.worksheet_range(nome_aba)
.map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
let linhas = range
.rows()
.take(5)
.map(|row| {
row.iter()
.map(|cell| {
use calamine::Data;
match cell {
Data::Empty => String::new(),
Data::String(s) => s.clone(),
Data::Float(f) => {
if f.fract() == 0.0 {
format!("{}", *f as i64)
} else {
format!("{}", f)
}
}
Data::Int(i) => i.to_string(),
Data::Bool(b) => b.to_string(),
Data::DateTime(dt) => dt.to_string(),
Data::DateTimeIso(s) => s.clone(),
Data::DurationIso(s) => s.clone(),
Data::Error(_) => String::new(),
}
})
.collect()
})
.collect();
Ok(linhas)
}
/// Converte uma notação LetraLinha (ex: "B3") para (coluna_base0, linha_base1). /// Converte uma notação LetraLinha (ex: "B3") para (coluna_base0, linha_base1).
/// ///
/// Retorna `None` se a notação for inválida. /// Retorna `None` se a notação for inválida.
+241 -5
View File
@@ -21,6 +21,7 @@ use crate::infrastructure::{
use egui::Context; use egui::Context;
use rusqlite::Connection; use rusqlite::Connection;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::mpsc;
/// Estado global da aplicação. /// Estado global da aplicação.
pub enum EstadoApp { pub enum EstadoApp {
@@ -39,6 +40,8 @@ pub enum EstadoApp {
}, },
/// Gerenciamento de layouts. /// Gerenciamento de layouts.
GerenciandoLayouts, GerenciandoLayouts,
/// Análise em execução em background (thread separada).
Analisando,
} }
/// Modal bloqueante a ser exibido sobre qualquer tela. /// Modal bloqueante a ser exibido sobre qualquer tela.
@@ -50,6 +53,8 @@ pub struct Modal {
pub tipo: TipoModal, pub tipo: TipoModal,
/// Para modal de confirmação, a ação ao confirmar. /// Para modal de confirmação, a ação ao confirmar.
pub acao_confirmacao: Option<AcaoModal>, pub acao_confirmacao: Option<AcaoModal>,
/// Para modal com campo de texto (InputTexto).
pub input_texto: String,
} }
#[derive(Default, Clone, PartialEq)] #[derive(Default, Clone, PartialEq)]
@@ -59,6 +64,8 @@ pub enum TipoModal {
Aviso, Aviso,
Erro, Erro,
Confirmacao, Confirmacao,
/// Modal com campo de texto para entrada do usuário.
InputTexto,
} }
#[derive(Clone)] #[derive(Clone)]
@@ -66,6 +73,32 @@ pub enum AcaoModal {
ConfirmarExpansaoFaltantes, ConfirmarExpansaoFaltantes,
ConfirmarExclusaoLayout(i64), ConfirmarExclusaoLayout(i64),
SobrescreverLayout, SobrescreverLayout,
ConfirmarNovaAnalise,
/// Salvar a configuração atual como novo layout, usando modal.input_texto como nome.
SalvarLayoutConfig,
}
/// Resultado enviado pela thread de análise de volta para a UI.
pub enum ResultadoPendente {
/// Análise concluída com sucesso.
Concluido {
resultado: ResultadoAnalise,
/// Avisos de importação (None quando vier do path de expansão).
avisos: Option<ResumoAvisos>,
/// Notas importadas (None quando o App já as possui).
notas: Option<Vec<Nota>>,
},
/// Pré-análise concluída mas precisa de confirmação do usuário.
AguardandoConfirmacao {
pre: ResultadoPreAnalise,
series_excessivas: Vec<(String, u64)>,
avisos: ResumoAvisos,
notas: Vec<Nota>,
},
/// Arquivo importado não continha notas válidas.
Vazio,
/// Erro durante importação ou análise.
Erro(String),
} }
/// Struct principal da aplicação egui. /// Struct principal da aplicação egui.
@@ -101,6 +134,11 @@ pub struct App {
pub pagina_faltantes: usize, pub pagina_faltantes: usize,
pub pagina_duplicatas: usize, pub pagina_duplicatas: usize,
pub itens_por_pagina: usize, pub itens_por_pagina: usize,
// Pré-visualização das primeiras linhas do arquivo
pub preview_arquivo: Option<Vec<Vec<String>>>,
// Canal para receber resultado da análise em background
pub resultado_pendente: Option<mpsc::Receiver<ResultadoPendente>>,
} }
impl Default for App { impl Default for App {
@@ -123,6 +161,8 @@ impl Default for App {
pagina_faltantes: 0, pagina_faltantes: 0,
pagina_duplicatas: 0, pagina_duplicatas: 0,
itens_por_pagina: 100, itens_por_pagina: 100,
preview_arquivo: None,
resultado_pendente: None,
} }
} }
} }
@@ -137,6 +177,7 @@ impl App {
EstadoApp::ExibindoResultado(_) => 3, EstadoApp::ExibindoResultado(_) => 3,
EstadoApp::ConfirmandoIntervalo { .. } => 4, EstadoApp::ConfirmandoIntervalo { .. } => 4,
EstadoApp::GerenciandoLayouts => 5, EstadoApp::GerenciandoLayouts => 5,
EstadoApp::Analisando => 6,
} }
} }
@@ -175,6 +216,7 @@ impl App {
mensagem: msg.into(), mensagem: msg.into(),
tipo: TipoModal::Erro, tipo: TipoModal::Erro,
acao_confirmacao: None, acao_confirmacao: None,
input_texto: String::new(),
}; };
} }
@@ -185,6 +227,7 @@ impl App {
mensagem: msg.into(), mensagem: msg.into(),
tipo: TipoModal::Aviso, tipo: TipoModal::Aviso,
acao_confirmacao: None, acao_confirmacao: None,
input_texto: String::new(),
}; };
} }
@@ -200,10 +243,23 @@ impl App {
mensagem: msg.into(), mensagem: msg.into(),
tipo: TipoModal::Confirmacao, tipo: TipoModal::Confirmacao,
acao_confirmacao: Some(acao), acao_confirmacao: Some(acao),
input_texto: String::new(),
}; };
} }
/// Recarrega a lista de layouts do banco. /// Recarrega a lista de layouts do banco.
/// Exibe modal com campo de texto para salvar o layout atual como novo preset.
pub fn exibir_modal_salvar_layout(&mut self) {
self.modal = Modal {
visivel: true,
titulo: "Salvar como layout...".to_string(),
mensagem: "Nome do layout:".to_string(),
tipo: TipoModal::InputTexto,
acao_confirmacao: Some(AcaoModal::SalvarLayoutConfig),
input_texto: self.nome_layout_atual.clone(),
};
}
pub fn recarregar_layouts(&mut self) { pub fn recarregar_layouts(&mut self) {
if let Some(conn) = &self.conn { if let Some(conn) = &self.conn {
match listar_layouts(conn) { match listar_layouts(conn) {
@@ -227,14 +283,26 @@ impl App {
egui::Window::new(&titulo) egui::Window::new(&titulo)
.collapsible(false) .collapsible(false)
.resizable(false) .resizable(false)
.min_width(320.0)
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.show(ctx, |ui| { .show(ctx, |ui| {
ui.add_space(8.0); ui.add_space(8.0);
ui.label(&mensagem); ui.label(&mensagem);
if tipo == TipoModal::InputTexto {
ui.add_space(4.0);
let resp = ui.add(
egui::TextEdit::singleline(&mut self.modal.input_texto)
.desired_width(280.0)
.hint_text("Nome do layout"),
);
resp.request_focus();
}
ui.add_space(12.0); ui.add_space(12.0);
ui.horizontal(|ui| { ui.horizontal(|ui| {
if tipo == TipoModal::Confirmacao { if tipo == TipoModal::Confirmacao || tipo == TipoModal::InputTexto {
if ui.button("✔ Confirmar").clicked() { if ui.button("✔ Confirmar").clicked() {
self.modal.visivel = false; self.modal.visivel = false;
if let Some(acao) = acao.clone() { if let Some(acao) = acao.clone() {
@@ -243,6 +311,7 @@ impl App {
} }
if ui.button("✖ Cancelar").clicked() { if ui.button("✖ Cancelar").clicked() {
self.modal.visivel = false; self.modal.visivel = false;
self.modal.input_texto.clear();
} }
} else { } else {
if ui.button("OK").clicked() { if ui.button("OK").clicked() {
@@ -256,12 +325,20 @@ impl App {
fn executar_acao_modal(&mut self, acao: AcaoModal) { fn executar_acao_modal(&mut self, acao: AcaoModal) {
match acao { match acao {
AcaoModal::ConfirmarExpansaoFaltantes => { AcaoModal::ConfirmarExpansaoFaltantes => {
// Retirar pre-análise do estado e expandir
if let EstadoApp::ConfirmandoIntervalo { pre, .. } = if let EstadoApp::ConfirmandoIntervalo { pre, .. } =
std::mem::replace(&mut self.estado, EstadoApp::Importando) std::mem::replace(&mut self.estado, EstadoApp::Analisando)
{ {
let resultado = expandir_analise(pre, &self.notas_importadas); let notas = self.notas_importadas.clone();
self.estado = EstadoApp::ExibindoResultado(resultado); let (tx, rx) = mpsc::channel();
self.resultado_pendente = Some(rx);
std::thread::spawn(move || {
let resultado = expandir_analise(pre, &notas);
let _ = tx.send(ResultadoPendente::Concluido {
resultado,
avisos: None,
notas: None,
});
});
} }
} }
AcaoModal::ConfirmarExclusaoLayout(id) => { AcaoModal::ConfirmarExclusaoLayout(id) => {
@@ -274,6 +351,102 @@ impl App {
} }
} }
AcaoModal::SobrescreverLayout => {} // Handled inline in layouts screen AcaoModal::SobrescreverLayout => {} // Handled inline in layouts screen
AcaoModal::ConfirmarNovaAnalise => {
self.notas_importadas.clear();
self.preview_arquivo = None;
self.estado = EstadoApp::Importando;
}
AcaoModal::SalvarLayoutConfig => {
let nome = self.modal.input_texto.trim().to_string();
self.modal.input_texto.clear();
if nome.is_empty() {
self.exibir_erro("O nome do layout não pode ser vazio.");
return;
}
let layout = match &self.tipo_arquivo_atual {
TipoArquivo::Csv => Layout::Csv {
id: None,
nome: nome.clone(),
config: self.layout_csv_atual.clone(),
},
TipoArquivo::Xlsx => Layout::Xlsx {
id: None,
nome: nome.clone(),
config: self.layout_xlsx_atual.clone(),
},
};
let resultado = if let Some(conn) = &self.conn {
Some(salvar_layout(conn, &layout))
} else {
None
};
match resultado {
Some(Ok(_)) => {
self.nome_layout_atual = nome;
self.recarregar_layouts();
self.exibir_aviso("Layout salvo", "Layout salvo com sucesso.");
}
Some(Err(e)) => self.exibir_erro(e),
None => {}
}
}
}
}
/// Processa o resultado recebido do canal de análise em background.
pub fn processar_resultado_pendente(&mut self, resultado: ResultadoPendente) {
match resultado {
ResultadoPendente::Concluido { resultado, avisos, notas } => {
if let Some(n) = notas {
self.notas_importadas = n;
}
if let Some(av) = &avisos {
self.avisos_importacao = if av.tem_avisos() { Some(av.clone()) } else { None };
}
self.estado = EstadoApp::ExibindoResultado(resultado);
if let Some(av) = &self.avisos_importacao.clone() {
if av.tem_avisos() {
let linhas = av.linhas_para_exibir().join("\n");
self.exibir_aviso("Avisos de Importação", linhas);
}
}
}
ResultadoPendente::AguardandoConfirmacao {
pre,
series_excessivas,
avisos,
notas,
} => {
self.notas_importadas = notas;
self.avisos_importacao = if avisos.tem_avisos() { Some(avisos) } else { None };
let msg = series_excessivas
.iter()
.map(|(serie, count)| {
format!("Série {}: intervalo de {} faltantes detectado", serie, count)
})
.collect::<Vec<_>>()
.join("\n");
self.estado = EstadoApp::ConfirmandoIntervalo {
pre,
series_excessivas,
};
self.exibir_confirmacao(
"Intervalo muito grande",
format!(
"As seguintes séries têm um número muito alto de faltantes:\n\n{}\n\nDeseja continuar mesmo assim?",
msg
),
AcaoModal::ConfirmarExpansaoFaltantes,
);
}
ResultadoPendente::Vazio => {
self.estado = EstadoApp::ConfigurandoColunas;
self.exibir_aviso("Aviso", "Nenhuma nota válida encontrada no arquivo.");
}
ResultadoPendente::Erro(e) => {
self.estado = EstadoApp::ConfigurandoColunas;
self.exibir_erro(format!("Erro ao importar arquivo: {}", e));
}
} }
} }
@@ -316,11 +489,65 @@ impl App {
} }
} }
impl App {
/// Renderiza o breadcrumb de etapas no topo (exceto na tela de layouts).
pub fn renderizar_breadcrumb(&self, ctx: &Context) {
let passo_ativo: usize = match self.tela_atual() {
0 | 1 => 1,
2 | 4 | 6 => 2,
3 => 3,
_ => return, // GerenciandoLayouts: sem breadcrumb
};
egui::TopBottomPanel::top("breadcrumb").show(ctx, |ui| {
ui.add_space(4.0);
ui.horizontal(|ui| {
for (i, label) in ["① Arquivo", "② Colunas", "③ Resultado"].iter().enumerate() {
let n = i + 1;
let texto = egui::RichText::new(*label);
if n == passo_ativo {
ui.label(texto.strong());
} else if n < passo_ativo {
ui.label(texto);
} else {
ui.label(texto.weak());
}
if n < 3 {
ui.label(egui::RichText::new(" ").weak());
}
}
});
ui.add_space(4.0);
});
}
}
impl eframe::App for App { impl eframe::App for App {
fn update(&mut self, ctx: &Context, _frame: &mut eframe::Frame) { fn update(&mut self, ctx: &Context, _frame: &mut eframe::Frame) {
// Verificar resultado pendente da análise em background
if let Some(rx) = self.resultado_pendente.take() {
match rx.try_recv() {
Ok(resultado) => {
self.processar_resultado_pendente(resultado);
}
Err(mpsc::TryRecvError::Empty) => {
// Ainda processando: devolver receiver e solicitar repaint
self.resultado_pendente = Some(rx);
ctx.request_repaint();
}
Err(mpsc::TryRecvError::Disconnected) => {
self.estado = EstadoApp::ConfigurandoColunas;
self.exibir_erro("Erro interno: a análise foi interrompida inesperadamente.");
}
}
}
// Sempre renderizar modal por cima de tudo // Sempre renderizar modal por cima de tudo
self.renderizar_modal(ctx); self.renderizar_modal(ctx);
// Breadcrumb de etapas no topo
self.renderizar_breadcrumb(ctx);
// Determinar qual tela exibir sem borrar self.estado // Determinar qual tela exibir sem borrar self.estado
let tela_id = self.tela_atual(); let tela_id = self.tela_atual();
@@ -333,6 +560,15 @@ impl eframe::App for App {
3 => crate::ui::screens::resultado::renderizar(ui, ctx, self), 3 => crate::ui::screens::resultado::renderizar(ui, ctx, self),
4 => crate::ui::screens::configuracao_colunas::renderizar(ui, ctx, self), 4 => crate::ui::screens::configuracao_colunas::renderizar(ui, ctx, self),
5 => crate::ui::screens::layouts::renderizar(ui, ctx, self), 5 => crate::ui::screens::layouts::renderizar(ui, ctx, self),
6 => {
ui.centered_and_justified(|ui| {
ui.label(
egui::RichText::new("⏳ Analisando... aguarde.")
.size(22.0)
.strong(),
);
});
}
_ => {} _ => {}
} }
}); });
+124 -38
View File
@@ -1,6 +1,9 @@
use crate::application::usecases::importar_arquivo::{importar_csv, importar_xlsx}; use crate::application::usecases::importar_arquivo::{importar_csv, importar_xlsx};
use crate::domain::entities::layout::TipoArquivo; use crate::application::usecases::executar_analise::{
use crate::ui::app::{App, EstadoApp}; expandir_analise, pre_analisar, series_com_intervalo_excessivo,
};
use crate::domain::entities::layout::{Layout, TipoArquivo};
use crate::ui::app::{App, EstadoApp, ResultadoPendente};
use egui::{Context, Ui}; use egui::{Context, Ui};
/// Renderiza a tela de configuração de colunas. /// Renderiza a tela de configuração de colunas.
@@ -12,6 +15,51 @@ pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
ui.label(format!("Arquivo: {}", caminho.display())); ui.label(format!("Arquivo: {}", caminho.display()));
} }
ui.add_space(8.0);
// Seletor de layout
let tipo_atual = app.tipo_arquivo_atual.clone();
let opcoes_layout: Vec<(i64, String)> = app
.layouts_salvos
.iter()
.filter(|l| l.tipo() == tipo_atual)
.filter_map(|l| l.id().map(|id| (id, l.nome().to_string())))
.collect();
let nome_layout_atual = app.nome_layout_atual.clone();
ui.horizontal(|ui| {
ui.label("Layout:");
egui::ComboBox::from_id_salt("combo_layouts_config")
.selected_text(if nome_layout_atual.is_empty() {
"— Selecionar layout —"
} else {
&nome_layout_atual
})
.show_ui(ui, |ui| {
for (id, nome) in &opcoes_layout {
if ui
.selectable_label(nome_layout_atual == *nome, nome.as_str())
.clicked()
{
app.nome_layout_atual = nome.clone();
if let Some(layout) =
app.layouts_salvos.iter().find(|l| l.id() == Some(*id))
{
let layout = layout.clone();
match &layout {
Layout::Csv { config, .. } => {
app.layout_csv_atual = config.clone();
}
Layout::Xlsx { config, .. } => {
app.layout_xlsx_atual = config.clone();
}
}
}
}
}
});
});
ui.add_space(12.0); ui.add_space(12.0);
match app.tipo_arquivo_atual.clone() { match app.tipo_arquivo_atual.clone() {
@@ -19,6 +67,14 @@ pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
TipoArquivo::Xlsx => renderizar_xlsx(ui, app), TipoArquivo::Xlsx => renderizar_xlsx(ui, app),
} }
// Pré-visualização do arquivo
if let Some(preview) = &app.preview_arquivo.clone() {
ui.add_space(8.0);
ui.separator();
ui.add_space(4.0);
crate::ui::screens::renderizar_tabela_preview(ui, preview);
}
ui.add_space(16.0); ui.add_space(16.0);
ui.separator(); ui.separator();
ui.add_space(8.0); ui.add_space(8.0);
@@ -40,9 +96,13 @@ pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
ui.add_enabled_ui(valido && app.caminho_arquivo.is_some(), |ui| { ui.add_enabled_ui(valido && app.caminho_arquivo.is_some(), |ui| {
if ui.button("▶ Importar e Analisar").clicked() { if ui.button("▶ Importar e Analisar").clicked() {
executar_importacao(app); executar_importacao(app, ctx);
} }
}); });
if ui.button("💾 Salvar como layout...").clicked() {
app.exibir_modal_salvar_layout();
}
}); });
} }
@@ -60,6 +120,7 @@ fn renderizar_csv(ui: &mut Ui, app: &mut App) {
'\t' => "Tabulação (Tab)", '\t' => "Tabulação (Tab)",
_ => "Outro", _ => "Outro",
}; };
let mut delim_mudou = false;
egui::ComboBox::from_id_salt("combo_delimitador") egui::ComboBox::from_id_salt("combo_delimitador")
.selected_text(delim_str) .selected_text(delim_str)
.show_ui(ui, |ui| { .show_ui(ui, |ui| {
@@ -68,6 +129,7 @@ fn renderizar_csv(ui: &mut Ui, app: &mut App) {
.clicked() .clicked()
{ {
app.layout_csv_atual.delimitador = ','; app.layout_csv_atual.delimitador = ',';
delim_mudou = true;
} }
if ui if ui
.selectable_label( .selectable_label(
@@ -77,6 +139,7 @@ fn renderizar_csv(ui: &mut Ui, app: &mut App) {
.clicked() .clicked()
{ {
app.layout_csv_atual.delimitador = ';'; app.layout_csv_atual.delimitador = ';';
delim_mudou = true;
} }
if ui if ui
.selectable_label( .selectable_label(
@@ -86,8 +149,19 @@ fn renderizar_csv(ui: &mut Ui, app: &mut App) {
.clicked() .clicked()
{ {
app.layout_csv_atual.delimitador = '\t'; app.layout_csv_atual.delimitador = '\t';
delim_mudou = true;
} }
}); });
if delim_mudou {
if let Some(caminho) = &app.caminho_arquivo.clone() {
app.preview_arquivo = crate::infrastructure::csv_reader::preview_csv(
caminho,
app.layout_csv_atual.delimitador as u8,
&app.layout_csv_atual.encoding.clone(),
5,
).ok();
}
}
}); });
// Encoding // Encoding
@@ -297,49 +371,61 @@ fn verificar_duplicados(indices: &[(String, usize)], erros: &mut Vec<String>) {
} }
} }
fn executar_importacao(app: &mut App) { fn executar_importacao(app: &mut App, ctx: &egui::Context) {
let caminho = match &app.caminho_arquivo { let caminho = match &app.caminho_arquivo {
Some(p) => p.clone(), Some(p) => p.clone(),
None => return, None => return,
}; };
let resultado = match app.tipo_arquivo_atual.clone() { let tipo = app.tipo_arquivo_atual.clone();
TipoArquivo::Csv => { let layout_csv = app.layout_csv_atual.clone();
importar_csv(&caminho, &app.layout_csv_atual).map_err(|e| e.to_string()) let layout_xlsx = app.layout_xlsx_atual.clone();
}
TipoArquivo::Xlsx => { let (tx, rx) = std::sync::mpsc::channel();
importar_xlsx(&caminho, &app.layout_xlsx_atual).map_err(|e| e.to_string()) app.resultado_pendente = Some(rx);
} app.estado = EstadoApp::Analisando;
ctx.request_repaint();
std::thread::spawn(move || {
// 1. Importar arquivo
let res_importacao = match tipo {
TipoArquivo::Csv => importar_csv(&caminho, &layout_csv).map_err(|e| e.to_string()),
TipoArquivo::Xlsx => importar_xlsx(&caminho, &layout_xlsx).map_err(|e| e.to_string()),
}; };
match resultado { let res = match res_importacao {
Ok(res) => { Err(e) => ResultadoPendente::Erro(e),
if res.notas.is_empty() { Ok(importado) => {
app.exibir_aviso("Aviso", "Nenhuma nota válida encontrada no arquivo."); if importado.notas.is_empty() {
return; ResultadoPendente::Vazio
}
let avisos = res.avisos.clone();
app.notas_importadas = res.notas;
app.avisos_importacao = if avisos.tem_avisos() {
Some(avisos.clone())
} else { } else {
None let avisos = importado.avisos.clone();
let notas = importado.notas;
// 2. Pré-análise
let pre = pre_analisar(&notas);
let excessivos = series_com_intervalo_excessivo(&pre);
if !excessivos.is_empty() {
ResultadoPendente::AguardandoConfirmacao {
pre,
series_excessivas: excessivos,
avisos,
notas,
}
} else {
// 3. Expandir faltantes
let resultado = expandir_analise(pre, &notas);
ResultadoPendente::Concluido {
resultado,
avisos: Some(avisos),
notas: Some(notas),
}
}
}
}
}; };
// Executar análise let _ = tx.send(res);
app.executar_analise(); });
// Exibir avisos consolidados após análise
if let Some(av) = &app.avisos_importacao {
if av.tem_avisos() {
let linhas = av.linhas_para_exibir().join("\n");
app.exibir_aviso("Avisos de Importação", linhas);
}
}
}
Err(e) => {
app.exibir_erro(format!("Erro ao importar arquivo: {}", e));
}
}
} }
+21 -1
View File
@@ -102,6 +102,19 @@ pub fn renderizar_selecao_aba(ui: &mut Ui, _ctx: &Context, app: &mut App) {
for aba in &abas { for aba in &abas {
if ui.selectable_label(aba_atual == *aba, aba).clicked() { if ui.selectable_label(aba_atual == *aba, aba).clicked() {
app.layout_xlsx_atual.aba = aba.clone(); app.layout_xlsx_atual.aba = aba.clone();
// Gerar pré-visualização da aba selecionada
app.preview_arquivo = crate::infrastructure::xlsx_reader::preview_xlsx(
&caminho,
aba,
).ok();
}
}
// Pré-visualização da aba selecionada
if !app.layout_xlsx_atual.aba.is_empty() {
if let Some(preview) = &app.preview_arquivo {
ui.add_space(8.0);
crate::ui::screens::renderizar_tabela_preview(ui, preview);
} }
} }
@@ -137,8 +150,15 @@ fn on_arquivo_selecionado(app: &mut App, caminho: PathBuf) {
match extensao.as_str() { match extensao.as_str() {
"csv" => { "csv" => {
app.tipo_arquivo_atual = TipoArquivo::Csv; app.tipo_arquivo_atual = TipoArquivo::Csv;
app.caminho_arquivo = Some(caminho); app.caminho_arquivo = Some(caminho.clone());
app.notas_importadas.clear(); app.notas_importadas.clear();
// Gerar pré-visualização com o delimitador atual
app.preview_arquivo = crate::infrastructure::csv_reader::preview_csv(
&caminho,
app.layout_csv_atual.delimitador as u8,
&app.layout_csv_atual.encoding.clone(),
5,
).ok();
} }
"xlsx" | "xls" => { "xlsx" | "xls" => {
app.tipo_arquivo_atual = TipoArquivo::Xlsx; app.tipo_arquivo_atual = TipoArquivo::Xlsx;
+68
View File
@@ -2,3 +2,71 @@ pub mod configuracao_colunas;
pub mod import; pub mod import;
pub mod layouts; pub mod layouts;
pub mod resultado; pub mod resultado;
/// Converte um índice de coluna base-0 para a notação de letras do Excel (A, B, ..., Z, AA, ...).
fn indice_para_letra(mut idx: usize) -> String {
let mut resultado = String::new();
loop {
resultado.insert(0, (b'A' + (idx % 26) as u8) as char);
if idx < 26 {
break;
}
idx = idx / 26 - 1;
}
resultado
}
/// Renderiza uma tabela simples de pré-visualização do arquivo.
/// Exibe uma linha de cabeçalho com letras no estilo Excel (A, B, C, ...)
/// seguida pelas linhas de dados.
pub fn renderizar_tabela_preview(ui: &mut egui::Ui, linhas: &[Vec<String>]) {
let num_colunas = linhas.iter().map(|l| l.len()).max().unwrap_or(0);
if num_colunas == 0 {
return;
}
ui.label(
egui::RichText::new(format!(
"Pré-visualização ({} linha(s))",
linhas.len()
))
.small()
.weak(),
);
ui.add_space(2.0);
egui::ScrollArea::horizontal()
.id_salt("scroll_preview")
.max_height(160.0)
.show(ui, |ui| {
egui::Grid::new("tabela_preview")
.striped(true)
.spacing([8.0, 2.0])
.show(ui, |ui| {
// Linha de cabeçalho: letras A, B, C, ... com índice base-0 entre parênteses
for i in 0..num_colunas {
ui.label(
egui::RichText::new(format!("{} ({})", indice_para_letra(i), i))
.strong()
.monospace(),
);
}
ui.end_row();
// Linhas de dados
for linha in linhas {
for col in 0..num_colunas {
let celula = linha.get(col).map(|s| s.as_str()).unwrap_or("");
let texto = if celula.chars().count() > 30 {
let truncado: String = celula.chars().take(30).collect();
format!("{}...", truncado)
} else {
celula.to_string()
};
ui.label(egui::RichText::new(texto).monospace().small());
}
ui.end_row();
}
});
});
}
+12 -10
View File
@@ -4,7 +4,7 @@ use crate::domain::{
services::parser_monetario::formatar_valor_br, services::parser_monetario::formatar_valor_br,
}; };
use crate::infrastructure::pdf_generator::GenpdfGenerator; use crate::infrastructure::pdf_generator::GenpdfGenerator;
use crate::ui::app::{App, EstadoApp}; use crate::ui::app::{AcaoModal, App, EstadoApp};
use egui::{Context, Ui}; use egui::{Context, Ui};
const OPCOES_PAGINA: &[usize] = &[50, 100, 200, 1000]; const OPCOES_PAGINA: &[usize] = &[50, 100, 200, 1000];
@@ -22,9 +22,11 @@ pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
ui.horizontal(|ui| { ui.horizontal(|ui| {
if ui.button("< Nova Análise").clicked() { if ui.button("< Nova Análise").clicked() {
app.estado = EstadoApp::Importando; app.exibir_confirmacao(
app.notas_importadas.clear(); "Nova Análise",
return; "Deseja iniciar uma nova análise? O resultado atual será descartado.",
AcaoModal::ConfirmarNovaAnalise,
);
} }
if ui.button("⚙ Reconfigurar Colunas").clicked() { if ui.button("⚙ Reconfigurar Colunas").clicked() {
@@ -58,12 +60,6 @@ pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
ui.separator(); ui.separator();
egui::ScrollArea::vertical().show(ui, |ui| { egui::ScrollArea::vertical().show(ui, |ui| {
// Totais
renderizar_totais(ui, &resultado);
ui.add_space(12.0);
ui.separator();
// Faltantes // Faltantes
renderizar_faltantes(ui, app, &resultado); renderizar_faltantes(ui, app, &resultado);
@@ -72,6 +68,12 @@ pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
// Duplicatas // Duplicatas
renderizar_duplicatas(ui, app, &resultado); renderizar_duplicatas(ui, app, &resultado);
ui.add_space(12.0);
ui.separator();
// Totais
renderizar_totais(ui, &resultado);
}); });
} }