Compare commits
5
Commits
b0e216a6ff
...
2c08f237e9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c08f237e9 | ||
|
|
dedb6a72c2 | ||
|
|
41c75e5ed1 | ||
|
|
0f728b9684 | ||
|
|
b2b91cda55 |
@@ -88,3 +88,39 @@ pub fn ler_csv(
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -97,6 +97,53 @@ pub fn ler_xlsx(
|
||||
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).
|
||||
///
|
||||
/// Retorna `None` se a notação for inválida.
|
||||
|
||||
+241
-5
@@ -21,6 +21,7 @@ use crate::infrastructure::{
|
||||
use egui::Context;
|
||||
use rusqlite::Connection;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc;
|
||||
|
||||
/// Estado global da aplicação.
|
||||
pub enum EstadoApp {
|
||||
@@ -39,6 +40,8 @@ pub enum EstadoApp {
|
||||
},
|
||||
/// Gerenciamento de layouts.
|
||||
GerenciandoLayouts,
|
||||
/// Análise em execução em background (thread separada).
|
||||
Analisando,
|
||||
}
|
||||
|
||||
/// Modal bloqueante a ser exibido sobre qualquer tela.
|
||||
@@ -50,6 +53,8 @@ pub struct Modal {
|
||||
pub tipo: TipoModal,
|
||||
/// Para modal de confirmação, a ação ao confirmar.
|
||||
pub acao_confirmacao: Option<AcaoModal>,
|
||||
/// Para modal com campo de texto (InputTexto).
|
||||
pub input_texto: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, PartialEq)]
|
||||
@@ -59,6 +64,8 @@ pub enum TipoModal {
|
||||
Aviso,
|
||||
Erro,
|
||||
Confirmacao,
|
||||
/// Modal com campo de texto para entrada do usuário.
|
||||
InputTexto,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -66,6 +73,32 @@ pub enum AcaoModal {
|
||||
ConfirmarExpansaoFaltantes,
|
||||
ConfirmarExclusaoLayout(i64),
|
||||
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.
|
||||
@@ -101,6 +134,11 @@ pub struct App {
|
||||
pub pagina_faltantes: usize,
|
||||
pub pagina_duplicatas: 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 {
|
||||
@@ -123,6 +161,8 @@ impl Default for App {
|
||||
pagina_faltantes: 0,
|
||||
pagina_duplicatas: 0,
|
||||
itens_por_pagina: 100,
|
||||
preview_arquivo: None,
|
||||
resultado_pendente: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -137,6 +177,7 @@ impl App {
|
||||
EstadoApp::ExibindoResultado(_) => 3,
|
||||
EstadoApp::ConfirmandoIntervalo { .. } => 4,
|
||||
EstadoApp::GerenciandoLayouts => 5,
|
||||
EstadoApp::Analisando => 6,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,6 +216,7 @@ impl App {
|
||||
mensagem: msg.into(),
|
||||
tipo: TipoModal::Erro,
|
||||
acao_confirmacao: None,
|
||||
input_texto: String::new(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -185,6 +227,7 @@ impl App {
|
||||
mensagem: msg.into(),
|
||||
tipo: TipoModal::Aviso,
|
||||
acao_confirmacao: None,
|
||||
input_texto: String::new(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -200,10 +243,23 @@ impl App {
|
||||
mensagem: msg.into(),
|
||||
tipo: TipoModal::Confirmacao,
|
||||
acao_confirmacao: Some(acao),
|
||||
input_texto: String::new(),
|
||||
};
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
if let Some(conn) = &self.conn {
|
||||
match listar_layouts(conn) {
|
||||
@@ -227,14 +283,26 @@ impl App {
|
||||
egui::Window::new(&titulo)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.min_width(320.0)
|
||||
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
|
||||
.show(ctx, |ui| {
|
||||
ui.add_space(8.0);
|
||||
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.horizontal(|ui| {
|
||||
if tipo == TipoModal::Confirmacao {
|
||||
if tipo == TipoModal::Confirmacao || tipo == TipoModal::InputTexto {
|
||||
if ui.button("✔ Confirmar").clicked() {
|
||||
self.modal.visivel = false;
|
||||
if let Some(acao) = acao.clone() {
|
||||
@@ -243,6 +311,7 @@ impl App {
|
||||
}
|
||||
if ui.button("✖ Cancelar").clicked() {
|
||||
self.modal.visivel = false;
|
||||
self.modal.input_texto.clear();
|
||||
}
|
||||
} else {
|
||||
if ui.button("OK").clicked() {
|
||||
@@ -256,12 +325,20 @@ impl App {
|
||||
fn executar_acao_modal(&mut self, acao: AcaoModal) {
|
||||
match acao {
|
||||
AcaoModal::ConfirmarExpansaoFaltantes => {
|
||||
// Retirar pre-análise do estado e expandir
|
||||
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);
|
||||
self.estado = EstadoApp::ExibindoResultado(resultado);
|
||||
let notas = self.notas_importadas.clone();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
self.resultado_pendente = Some(rx);
|
||||
std::thread::spawn(move || {
|
||||
let resultado = expandir_analise(pre, ¬as);
|
||||
let _ = tx.send(ResultadoPendente::Concluido {
|
||||
resultado,
|
||||
avisos: None,
|
||||
notas: None,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
AcaoModal::ConfirmarExclusaoLayout(id) => {
|
||||
@@ -274,6 +351,102 @@ impl App {
|
||||
}
|
||||
}
|
||||
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 {
|
||||
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
|
||||
self.renderizar_modal(ctx);
|
||||
|
||||
// Breadcrumb de etapas no topo
|
||||
self.renderizar_breadcrumb(ctx);
|
||||
|
||||
// Determinar qual tela exibir sem borrar self.estado
|
||||
let tela_id = self.tela_atual();
|
||||
|
||||
@@ -333,6 +560,15 @@ impl eframe::App for App {
|
||||
3 => crate::ui::screens::resultado::renderizar(ui, ctx, self),
|
||||
4 => crate::ui::screens::configuracao_colunas::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(),
|
||||
);
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use crate::application::usecases::importar_arquivo::{importar_csv, importar_xlsx};
|
||||
use crate::domain::entities::layout::TipoArquivo;
|
||||
use crate::ui::app::{App, EstadoApp};
|
||||
use crate::application::usecases::executar_analise::{
|
||||
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};
|
||||
|
||||
/// 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.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);
|
||||
|
||||
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),
|
||||
}
|
||||
|
||||
// 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.separator();
|
||||
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| {
|
||||
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)",
|
||||
_ => "Outro",
|
||||
};
|
||||
let mut delim_mudou = false;
|
||||
egui::ComboBox::from_id_salt("combo_delimitador")
|
||||
.selected_text(delim_str)
|
||||
.show_ui(ui, |ui| {
|
||||
@@ -68,6 +129,7 @@ fn renderizar_csv(ui: &mut Ui, app: &mut App) {
|
||||
.clicked()
|
||||
{
|
||||
app.layout_csv_atual.delimitador = ',';
|
||||
delim_mudou = true;
|
||||
}
|
||||
if ui
|
||||
.selectable_label(
|
||||
@@ -77,6 +139,7 @@ fn renderizar_csv(ui: &mut Ui, app: &mut App) {
|
||||
.clicked()
|
||||
{
|
||||
app.layout_csv_atual.delimitador = ';';
|
||||
delim_mudou = true;
|
||||
}
|
||||
if ui
|
||||
.selectable_label(
|
||||
@@ -86,8 +149,19 @@ fn renderizar_csv(ui: &mut Ui, app: &mut App) {
|
||||
.clicked()
|
||||
{
|
||||
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
|
||||
@@ -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 {
|
||||
Some(p) => p.clone(),
|
||||
None => return,
|
||||
};
|
||||
|
||||
let resultado = match app.tipo_arquivo_atual.clone() {
|
||||
TipoArquivo::Csv => {
|
||||
importar_csv(&caminho, &app.layout_csv_atual).map_err(|e| e.to_string())
|
||||
}
|
||||
TipoArquivo::Xlsx => {
|
||||
importar_xlsx(&caminho, &app.layout_xlsx_atual).map_err(|e| e.to_string())
|
||||
}
|
||||
};
|
||||
let tipo = app.tipo_arquivo_atual.clone();
|
||||
let layout_csv = app.layout_csv_atual.clone();
|
||||
let layout_xlsx = app.layout_xlsx_atual.clone();
|
||||
|
||||
match resultado {
|
||||
Ok(res) => {
|
||||
if res.notas.is_empty() {
|
||||
app.exibir_aviso("Aviso", "Nenhuma nota válida encontrada no arquivo.");
|
||||
return;
|
||||
}
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
app.resultado_pendente = Some(rx);
|
||||
app.estado = EstadoApp::Analisando;
|
||||
ctx.request_repaint();
|
||||
|
||||
let avisos = res.avisos.clone();
|
||||
app.notas_importadas = res.notas;
|
||||
app.avisos_importacao = if avisos.tem_avisos() {
|
||||
Some(avisos.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
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()),
|
||||
};
|
||||
|
||||
// Executar análise
|
||||
app.executar_analise();
|
||||
let res = match res_importacao {
|
||||
Err(e) => ResultadoPendente::Erro(e),
|
||||
Ok(importado) => {
|
||||
if importado.notas.is_empty() {
|
||||
ResultadoPendente::Vazio
|
||||
} else {
|
||||
let avisos = importado.avisos.clone();
|
||||
let notas = importado.notas;
|
||||
|
||||
// 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);
|
||||
// 2. Pré-análise
|
||||
let pre = pre_analisar(¬as);
|
||||
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, ¬as);
|
||||
ResultadoPendente::Concluido {
|
||||
resultado,
|
||||
avisos: Some(avisos),
|
||||
notas: Some(notas),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
app.exibir_erro(format!("Erro ao importar arquivo: {}", e));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let _ = tx.send(res);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -102,6 +102,19 @@ pub fn renderizar_selecao_aba(ui: &mut Ui, _ctx: &Context, app: &mut App) {
|
||||
for aba in &abas {
|
||||
if ui.selectable_label(aba_atual == *aba, aba).clicked() {
|
||||
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() {
|
||||
"csv" => {
|
||||
app.tipo_arquivo_atual = TipoArquivo::Csv;
|
||||
app.caminho_arquivo = Some(caminho);
|
||||
app.caminho_arquivo = Some(caminho.clone());
|
||||
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" => {
|
||||
app.tipo_arquivo_atual = TipoArquivo::Xlsx;
|
||||
|
||||
@@ -2,3 +2,71 @@ pub mod configuracao_colunas;
|
||||
pub mod import;
|
||||
pub mod layouts;
|
||||
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
@@ -4,7 +4,7 @@ use crate::domain::{
|
||||
services::parser_monetario::formatar_valor_br,
|
||||
};
|
||||
use crate::infrastructure::pdf_generator::GenpdfGenerator;
|
||||
use crate::ui::app::{App, EstadoApp};
|
||||
use crate::ui::app::{AcaoModal, App, EstadoApp};
|
||||
use egui::{Context, Ui};
|
||||
|
||||
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| {
|
||||
if ui.button("< Nova Análise").clicked() {
|
||||
app.estado = EstadoApp::Importando;
|
||||
app.notas_importadas.clear();
|
||||
return;
|
||||
app.exibir_confirmacao(
|
||||
"Nova Análise",
|
||||
"Deseja iniciar uma nova análise? O resultado atual será descartado.",
|
||||
AcaoModal::ConfirmarNovaAnalise,
|
||||
);
|
||||
}
|
||||
|
||||
if ui.button("⚙ Reconfigurar Colunas").clicked() {
|
||||
@@ -58,12 +60,6 @@ pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
|
||||
ui.separator();
|
||||
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
// Totais
|
||||
renderizar_totais(ui, &resultado);
|
||||
|
||||
ui.add_space(12.0);
|
||||
ui.separator();
|
||||
|
||||
// Faltantes
|
||||
renderizar_faltantes(ui, app, &resultado);
|
||||
|
||||
@@ -72,6 +68,12 @@ pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
|
||||
|
||||
// Duplicatas
|
||||
renderizar_duplicatas(ui, app, &resultado);
|
||||
|
||||
ui.add_space(12.0);
|
||||
ui.separator();
|
||||
|
||||
// Totais
|
||||
renderizar_totais(ui, &resultado);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user